From 8a8b07812210b6c3c8dd09cecb960421ab1636a5 Mon Sep 17 00:00:00 2001 From: Tigran Babloyan Date: Mon, 20 Jul 2026 14:35:20 +0400 Subject: [PATCH] feat(AF-613): dynamic variables for API connectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An API connector could only send static values, which ruled out every API whose contract requires a value computed per request — HMAC request signing, nonces, timestamps, correlation ids, idempotency keys. A submitter cannot hand-compute those for a governed request: the signature covers a body the reviewer saw hours earlier, and the timestamp would be stale by execution. A connector now declares named variables substituted into headers, path, query and body via {{name}} placeholders. They resolve after auth headers are built — including a freshly minted OAuth2 token — so an expression can sign the finished Authorization value, and {{request.*}} describes the request *before* substitution, so a body being signed still carries its own placeholder. That is what the motivating vendor scheme requires. Evaluation is template substitution over a fixed function set only: no scripting engine, mirroring the engine plugins' rejection of $where and Painless. Rendering is single-pass, so a resolved value is never re-scanned. Cycles and dangling references are 422s at save time, not failed runs after approval. Resolved values are never persisted, snapshotted or logged — and are scrubbed from upstream failure messages, since the JDK's IOException embeds the substituted URI into the reviewer-visible error_message. Also implements the issue's out-of-scope per-request overrides: a variable may be marked overridable and given a value per request, gated by a new per-connector can_override_variables grant. A secret-bearing variable can never be overridable (service check plus a DB CHECK), an override is an opaque literal that cannot expand into another variable's value, and overrides are persisted and shown to reviewers so an approval covers exactly what executes. Grouped requests deliberately do not accept them. The new grant flag is boxed on the wire so clients predating it still work — an absent primitive boolean 500s under Jackson 3. --- CLAUDE.md | 1 + README.md | 2 +- .../internal/AccessGrantMaterializer.java | 4 +- .../api/ApiConnectorGroupPermissionView.java | 1 + .../ApiConnectorPermissionLookupService.java | 1 + .../api/ApiConnectorPermissionView.java | 1 + .../api/ApiConnectorVariableAdminService.java | 30 + .../ApiConnectorVariableLookupService.java | 19 + ...ApiConnectorVariableNotFoundException.java | 10 + ...ApiConnectorVariableResolutionService.java | 24 + .../api/ApiConnectorVariableSummaryView.java | 16 + .../apigov/api/ApiConnectorVariableView.java | 25 + .../accessflow/apigov/api/ApiRequestView.java | 7 + .../apigov/api/ApiReviewService.java | 6 + .../apigov/api/ApiVariableAlgorithm.java | 16 + .../apigov/api/ApiVariableEncoding.java | 16 + .../apigov/api/ApiVariableKind.java | 36 ++ .../apigov/api/ApiVariableRequestContext.java | 38 ++ .../apigov/api/ApiVariableTargetType.java | 14 + .../CreateApiConnectorVariableCommand.java | 22 + ...antApiConnectorGroupPermissionCommand.java | 1 + .../GrantApiConnectorPermissionCommand.java | 1 + .../IllegalApiConnectorVariableException.java | 16 + .../ReorderApiConnectorVariablesCommand.java | 16 + .../apigov/api/ResolvedApiVariables.java | 42 ++ .../apigov/api/SubmitApiRequestCommand.java | 2 + ...ateApiConnectorGroupPermissionCommand.java | 1 + .../UpdateApiConnectorPermissionCommand.java | 1 + .../UpdateApiConnectorVariableCommand.java | 23 + .../apigov/internal/ApiExecutionService.java | 45 +- .../ApiRequestVariableSubstitution.java | 128 ++++ .../ApiVariableEvaluationException.java | 36 ++ .../apigov/internal/ApiVariableEvaluator.java | 149 +++++ .../apigov/internal/ApiVariableGraph.java | 143 +++++ .../apigov/internal/ApiVariableTargets.java | 50 ++ .../apigov/internal/ApiVariableTemplate.java | 182 ++++++ .../DefaultApiConnectorAdminService.java | 10 +- ...ltApiConnectorPermissionLookupService.java | 1 + ...faultApiConnectorVariableAdminService.java | 359 +++++++++++ ...ApiConnectorVariableResolutionService.java | 213 +++++++ .../internal/DefaultApiRequestService.java | 78 ++- .../internal/DefaultApiReviewService.java | 18 +- ...fectiveApiConnectorPermissionResolver.java | 13 +- .../config/ApigovRequestProperties.java | 7 +- .../ApiConnectorGroupPermissionEntity.java | 5 + .../ApiConnectorUserPermissionEntity.java | 5 + .../entity/ApiConnectorVariableEntity.java | 102 ++++ .../persistence/entity/ApiRequestEntity.java | 7 + .../repo/ApiConnectorVariableRepository.java | 25 + .../internal/web/ApiConnectorController.java | 27 + .../ApiConnectorGroupPermissionResponse.java | 3 +- .../web/ApiConnectorPermissionResponse.java | 3 +- .../web/ApiConnectorVariableController.java | 145 +++++ .../web/ApiConnectorVariableListResponse.java | 6 + .../web/ApiConnectorVariableResponse.java | 36 ++ ...iConnectorVariableSummaryListResponse.java | 7 + .../ApiConnectorVariableSummaryResponse.java | 21 + .../internal/web/ApiGovExceptionHandler.java | 15 + .../internal/web/ApiRequestResponse.java | 5 +- .../CreateApiConnectorVariableRequest.java | 36 ++ ...antApiConnectorGroupPermissionRequest.java | 3 + .../GrantApiConnectorPermissionRequest.java | 4 +- .../web/PendingApiReviewResponse.java | 5 +- .../ReorderApiConnectorVariablesRequest.java | 17 + .../internal/web/SubmitApiRequestRequest.java | 4 +- ...ateApiConnectorGroupPermissionRequest.java | 3 + .../UpdateApiConnectorPermissionRequest.java | 3 + .../UpdateApiConnectorVariableRequest.java | 41 ++ .../accessflow/audit/api/AuditAction.java | 4 + .../V121__api_connector_variables.sql | 81 +++ .../main/resources/i18n/messages.properties | 34 ++ .../resources/i18n/messages_de.properties | 34 ++ .../resources/i18n/messages_es.properties | 34 ++ .../resources/i18n/messages_fr.properties | 34 ++ .../resources/i18n/messages_hy.properties | 34 ++ .../resources/i18n/messages_ru.properties | 34 ++ .../resources/i18n/messages_zh_CN.properties | 34 ++ .../internal/AccessGrantMaterializerTest.java | 2 +- .../ApiConnectorVariablesIntegrationTest.java | 259 ++++++++ .../internal/ApiExecutionServiceTest.java | 141 ++++- .../ApiRequestVariableSubstitutionTest.java | 236 ++++++++ .../internal/ApiVariableEvaluatorTest.java | 214 +++++++ .../apigov/internal/ApiVariableGraphTest.java | 127 ++++ .../internal/ApiVariableTargetsTest.java | 75 +++ .../internal/ApiVariableTemplateTest.java | 167 ++++++ .../DefaultApiConnectorAdminServiceTest.java | 28 +- ...iConnectorPermissionLookupServiceTest.java | 2 +- ...tApiConnectorVariableAdminServiceTest.java | 560 ++++++++++++++++++ ...onnectorVariableResolutionServiceTest.java | 335 +++++++++++ .../DefaultApiRequestServiceTest.java | 161 ++++- .../internal/DefaultApiReviewServiceTest.java | 3 +- .../config/ApigovRequestPropertiesTest.java | 6 +- .../web/ApiConnectorControllerTest.java | 18 +- .../ApiConnectorVariableControllerTest.java | 173 ++++++ .../web/ApiRequestControllerTest.java | 4 +- ...piConnectorGroupPermissionRequestTest.java | 23 +- ...dateApiConnectorPermissionRequestTest.java | 23 +- .../internal/DefaultDashboardServiceTest.java | 4 +- .../DefaultRequestGroupServiceCrudTest.java | 2 +- docs/03-data-model.md | 40 ++ docs/04-api-spec.md | 36 +- docs/05-backend.md | 62 +- docs/06-frontend.md | 8 +- docs/07-security.md | 32 + docs/09-deployment.md | 1 + docs/12-roadmap.md | 1 + docs/17-api-governance.md | 62 +- e2e/tests/api-connector-variables.spec.ts | 105 ++++ .../src/api/apiConnectorVariables.test.ts | 96 +++ frontend/src/api/apiConnectorVariables.ts | 74 +++ frontend/src/api/apiConnectors.test.ts | 5 + .../components/apigov/ApiAuthoringPanel.tsx | 10 +- .../ApiConnectorPermissionsTab.test.tsx | 2 + .../apigov/ApiConnectorPermissionsTab.tsx | 23 +- .../apigov/ApiConnectorVariablesTab.test.tsx | 181 ++++++ .../apigov/ApiConnectorVariablesTab.tsx | 559 +++++++++++++++++ .../components/apigov/ApiRequestComposer.tsx | 60 +- frontend/src/locales/de.json | 112 +++- frontend/src/locales/en.json | 112 +++- frontend/src/locales/es.json | 112 +++- frontend/src/locales/fr.json | 112 +++- frontend/src/locales/hy.json | 112 +++- frontend/src/locales/ru.json | 112 +++- frontend/src/locales/zh-CN.json | 112 +++- .../pages/apigov/ApiConnectorSettingsPage.tsx | 2 + frontend/src/pages/apigov/ApiEditorPage.tsx | 12 + .../src/pages/apigov/ApiRequestDetailPage.tsx | 40 ++ .../src/pages/apigov/ApiReviewQueuePage.tsx | 16 +- .../src/pages/requestGroups/groupBuilder.ts | 7 +- frontend/src/types/api.ts | 72 +++ .../__tests__/apiRequestComposition.test.ts | 29 + frontend/src/utils/apiRequestComposition.ts | 9 +- frontend/src/utils/enumLabels.ts | 38 ++ website/README.md | 1 + website/docs/index.html | 32 + website/index.html | 2 +- 136 files changed, 7526 insertions(+), 113 deletions(-) create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableAdminService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableLookupService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableNotFoundException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableResolutionService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableSummaryView.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableView.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableAlgorithm.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableEncoding.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableKind.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableRequestContext.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableTargetType.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/CreateApiConnectorVariableCommand.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/IllegalApiConnectorVariableException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ReorderApiConnectorVariablesCommand.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/ResolvedApiVariables.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorVariableCommand.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiRequestVariableSubstitution.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluationException.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluator.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableGraph.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTargets.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTemplate.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableAdminService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableResolutionService.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorVariableEntity.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/repo/ApiConnectorVariableRepository.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableController.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableListResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableSummaryListResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableSummaryResponse.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/CreateApiConnectorVariableRequest.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ReorderApiConnectorVariablesRequest.java create mode 100644 backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorVariableRequest.java create mode 100644 backend/src/main/resources/db/migration/V121__api_connector_variables.sql create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiConnectorVariablesIntegrationTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiRequestVariableSubstitutionTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluatorTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableGraphTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTargetsTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTemplateTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableAdminServiceTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableResolutionServiceTest.java create mode 100644 backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableControllerTest.java create mode 100644 e2e/tests/api-connector-variables.spec.ts create mode 100644 frontend/src/api/apiConnectorVariables.test.ts create mode 100644 frontend/src/api/apiConnectorVariables.ts create mode 100644 frontend/src/components/apigov/ApiConnectorVariablesTab.test.tsx create mode 100644 frontend/src/components/apigov/ApiConnectorVariablesTab.tsx diff --git a/CLAUDE.md b/CLAUDE.md index f5c81808..d8cb653e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -273,6 +273,7 @@ com.bablsoft.accessflow/ | `ACCESSFLOW_APIGOV_REVIEW_TIMEOUT` | ISO-8601 duration. How long an API request may sit in `PENDING_REVIEW` before `ApiRequestTimeoutJob` auto-rejects it (default `PT24H`). | | `ACCESSFLOW_APIGOV_MAX_REQUEST_BODY_BYTES` | Cap on the total encoded size of a submitted API request body (#517) — raw text, x-www-form-urlencoded, or the base64-decoded size of form-data / binary file parts (files ride inline as bounded base64 since AccessFlow has no object storage). Exceeding it rejects the submission with HTTP 422. Default `5242880` (5 MiB). Binds `accessflow.apigov.max-request-body-bytes`. | | `ACCESSFLOW_APIGOV_MAX_RESPONSE_BYTES` | System-wide hard ceiling (#521) on a stored — and therefore downloadable — API response body; the absolute backstop above any per-connector `max_response_bytes` (effective cap is the **min** of the two). The body lives in a Postgres `text` column and is read fully into JVM memory during the call. Default `10485760` (10 MiB). Binds `accessflow.apigov.max-response-bytes`. | +| `ACCESSFLOW_APIGOV_MAX_VARIABLE_VALUE_BYTES` | Cap on a single resolved connector dynamic-variable value (AF-613). A digest or nonce is tens of bytes, so a larger value means a runaway expression or an abusive per-request override; exceeding it fails the execution, and a submitted override over the cap is rejected with HTTP 422 at submit time. Default `8192`. Binds `accessflow.apigov.max-variable-value-bytes`. | | `ACCESSFLOW_APIGOV_RESPONSE_PREVIEW_BYTES` | How much of the stored response snapshot is embedded inline in the API-request **detail** view (#521); beyond it the detail sets `response_snapshot_preview_truncated=true` and the full body is fetched via `GET /api-requests/{id}/response`. Default `65536` (64 KiB). Binds `accessflow.apigov.response-preview-bytes`. | | `ACCESSFLOW_APIGOV_OAUTH2_TOKEN_CACHE_SKEW` | ISO-8601 duration (#506). Safety skew subtracted from a fetched outbound-OAuth2 token's `expires_in` before `ConnectorOAuth2TokenService` caches it in Redis (`apigov:oauth2:token:`) (default `PT30S`). Binds `accessflow.apigov.oauth2-token-cache-skew`. | | `ACCESSFLOW_APIGOV_OAUTH2_TOKEN_REQUEST_TIMEOUT` | ISO-8601 duration (#506). Connect/read timeout for the `apigovOAuth2RestClient` posting to a connector's OAuth2 token endpoint (default `PT10S`). Binds `accessflow.apigov.oauth2-token-request-timeout`. | diff --git a/README.md b/README.md index 90332ebf..fff0dc94 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ A glance at the day-to-day flows engineers and approvers actually use. - **Elasticsearch & OpenSearch (NoSQL search)** — first-class search connectors, shipped as the fifth on-demand engine plugin (`engines/elasticsearch/`, low-level REST client); the same plugin JAR serves both **Elasticsearch** and the wire-compatible **OpenSearch**. Users write a JSON query envelope (`{"search":"logs-*","query":{…}}`, plus `count`, `get`/`mget`, `index`/`bulk`, `update_by_query`/`delete_by_query`, and index management) classified onto the same approval workflow; row-level security injects `bool.filter` clauses on keyword fields (fail-closed on writes into a policied index), and field masking applies recursively to `_source` fields including nested dot-paths. Server-side scripting (`script`, `runtime_mappings`, Painless) and cluster/system-index APIs are rejected up front. Each datasource authenticates with basic auth or an API key. - **Amazon DynamoDB (NoSQL key-value)** — a first-class key-value connector speaking **PartiQL**, shipped as the sixth on-demand engine plugin (`engines/dynamodb/`, AWS SDK for Java v2 over the url-connection HTTP client — no Netty). It is the first engine whose connection is **cloud credentials + region** rather than host/port: the region, access key id, secret access key, and an optional custom endpoint (DynamoDB Local / VPC) map onto the existing datasource fields. PartiQL SELECT / INSERT / UPDATE / DELETE classify onto the same approval workflow, and table management (`CreateTable`/`DeleteTable`/`UpdateTable`) arrives as a JSON command document; transaction/batch statements are rejected. Row-level security splices predicates into the PartiQL WHERE clause (positional parameters, any attribute), failing closed on INSERT-into-policied and deny-all, and field masking applies recursively by dot-path including nested maps/lists. - **Neo4j (NoSQL graph)** — a first-class graph connector speaking **Cypher** over Bolt, shipped as the seventh on-demand engine plugin (`engines/neo4j/`, native Neo4j Java driver). Cypher is clause-based, so the query type is the strongest write clause present (DELETE/REMOVE → DELETE, CREATE/MERGE → INSERT, SET → UPDATE, else a `MATCH … RETURN` / `SHOW` read → SELECT), with index/constraint/database/role schema commands as DDL; `LOAD CSV`, procedure calls outside a read-only allow-list, and multi-statement input are rejected up front. Row-level security ANDs property predicates onto each `MATCH`'s `WHERE` (Cypher named parameters, node-label policies), failing closed on anonymous / write-creates-policied-label shapes; field masking is label-aware and recursive. Connection is host/port + database with the SSL mode encoded in the Bolt scheme, **or** a full `bolt://` / `neo4j+s://` URI (Neo4j Aura / clustered routing). -- **API Access Governance (AF-500)** — govern outbound **API** calls (REST / SOAP / GraphQL / gRPC), not just databases. An admin registers an **API connector** (URL + auth + protocol + admin-defined default headers; secrets AES-256-GCM encrypted) — including **OAuth2 with automatic token fetch, caching & refresh** (client-credentials / refresh-token / resource-owner password), so no bearer token is pasted by hand — uploads its schema (OpenAPI / WSDL / GraphQL SDL / gRPC proto, by paste, file upload, or URL fetch) which is parsed into a normalized operation catalog with read/write classification, and shares governed connectivity with the team via per-user permissions. Users **compose calls like Postman** — query params, custom headers, raw / form-data / x-www-form-urlencoded / binary file bodies. Every call flows through the same machinery as a database query: AI risk scoring, attribute-based routing, multi-stage human approval (no self-approval), **connector-level response masking** (policies targeting a schema field, JSON path, XML/XPath, or regex — with a masking strategy and role / group / user reveal scoping) and **data-classification tags** (PII/PCI/PHI/GDPR/FINANCIAL/SENSITIVE that auto-derive a masking policy and raise the AI risk — AF-518), immutable response snapshots (downloadable in full), W3C trace-context propagation (filterable by trace/span id), break-glass, scheduled execution, and natural-language **text-to-API**. See [`docs/17-api-governance.md`](https://github.com/bablsoft/accessflow/blob/main/docs/17-api-governance.md). +- **API Access Governance (AF-500)** — govern outbound **API** calls (REST / SOAP / GraphQL / gRPC), not just databases. An admin registers an **API connector** (URL + auth + protocol + admin-defined default headers; secrets AES-256-GCM encrypted) — including **OAuth2 with automatic token fetch, caching & refresh** (client-credentials / refresh-token / resource-owner password), so no bearer token is pasted by hand — uploads its schema (OpenAPI / WSDL / GraphQL SDL / gRPC proto, by paste, file upload, or URL fetch) which is parsed into a normalized operation catalog with read/write classification, and shares governed connectivity with the team via per-user permissions. Users **compose calls like Postman** — query params, custom headers, raw / form-data / x-www-form-urlencoded / binary file bodies. Every call flows through the same machinery as a database query: AI risk scoring, attribute-based routing, multi-stage human approval (no self-approval), **connector-level response masking** (policies targeting a schema field, JSON path, XML/XPath, or regex — with a masking strategy and role / group / user reveal scoping) and **data-classification tags** (PII/PCI/PHI/GDPR/FINANCIAL/SENSITIVE that auto-derive a masking policy and raise the AI risk — AF-518), immutable response snapshots (downloadable in full), W3C trace-context propagation (filterable by trace/span id), **dynamic variables** that compute a value per request and substitute it into headers, path, query or body via `{{name}}` placeholders — HMAC request signing, nonces, timestamps, idempotency keys — resolved *after* authentication so a signature can cover the freshly minted token, with opt-in per-request overrides for requesters who hold the grant (AF-613), break-glass, scheduled execution, and natural-language **text-to-API**. See [`docs/17-api-governance.md`](https://github.com/bablsoft/accessflow/blob/main/docs/17-api-governance.md). - **Request chaining & grouping (AF-501)** — bundle several steps into one **grouped request** reviewed and approved as a single element, then executed as an **ordered sequence**. Members can mix **queries** across different datasources and **API calls** against governed connectors; a builder lets you add steps, drag-reorder them, and author each one in a **full-parity editing drawer** — query steps get the complete Query Editor surface (schema autocomplete, AI analyze, dry-run, text-to-SQL, templates), API steps the complete API Editor surface (operation picker, request composer, AI analyze, text-to-API) — with an aggregate risk badge, and saved **drafts re-open for editing** without losing the composed request. Bundling never weakens a member's policy: each member is validated against your permission for its target (break-glass groups require `can_break_glass` on **every** target), the required approvers are the **union** across all member plans, and the group is approved only when **every** member plan is satisfied — the submitter can never approve their own group. On execute, members run in order; on the first failure the run stops and the rest are skipped (`continue-on-error` runs them all instead). There is **no distributed rollback** — an approved group is not atomic, already-applied members stay, and this is surfaced clearly. Each member records its own snapshot + audit row alongside group-level audit and live WebSocket progress. - **Data Lifecycle Manager (AF-499, AF-519)** — govern not just *who reads* data, but *when it is retired*. Admins define **retention/erasure rules** per datasource — target a table / column set / classification tag with a retention window plus **arbitrary conditions** (a structured, parameter-bound predicate builder and a JSqlParser-validated raw-`WHERE` escape hatch) and an action (**hard-delete**, **soft-delete**, or **pseudonymize**) — with an optional **cron schedule**, a clustered scan job, a **dry-run preview**, and **automatic execution** through the proxy. Any user can file a GDPR/CCPA **right-to-erasure** request with the *same* rich configuration; it flows through AI-assisted scope detection and **review-plan-based peer review** (REVIEWER-eligible, multi-stage, no self-approval, auto-reject on timeout) before executing. Enforcement is transparent: soft-deleted rows vanish from reads, `DELETE`s become marker updates, and aged PII resolves to an irreversible **salted hash** at read time — so aggregates survive while the PII does not — all with tamper-evident **proof-of-deletion** audit records and a retention-adherence compliance report. - **Deploy anywhere** — `docker compose up` for local and small environments; Helm chart for Kubernetes production. diff --git a/backend/src/main/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializer.java b/backend/src/main/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializer.java index ee980953..8fa3983b 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializer.java +++ b/backend/src/main/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializer.java @@ -73,12 +73,14 @@ private void materializeConnectorGrant(AccessGrantRequestEntity entity, UUID app Instant expiresAt) { requireNoStandingConnectorPermission(entity); // grantPermission upserts on (connector_id, user_id), so an existing time-boxed row is - // replaced in place. Break-glass and response-field restrictions are never JIT-grantable. + // replaced in place. Break-glass, variable overrides (AF-613) and response-field + // restrictions are never JIT-grantable — each is an explicit admin decision per connector. var command = new GrantApiConnectorPermissionCommand( entity.getRequesterId(), entity.isCanRead(), entity.isCanWrite(), false, + false, expiresAt, toList(entity.getAllowedOperations()), null); diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorGroupPermissionView.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorGroupPermissionView.java index 1c9e9930..6f7bd9d1 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorGroupPermissionView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorGroupPermissionView.java @@ -14,6 +14,7 @@ public record ApiConnectorGroupPermissionView( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields, diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorPermissionLookupService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorPermissionLookupService.java index 0a21fe77..5ed2a1b9 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorPermissionLookupService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorPermissionLookupService.java @@ -34,6 +34,7 @@ record ApiConnectorPermissionLookupView( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, List allowedOperations, Instant expiresAt) { } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorPermissionView.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorPermissionView.java index 0fd95ebd..1553a391 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorPermissionView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorPermissionView.java @@ -14,6 +14,7 @@ public record ApiConnectorPermissionView( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields, diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableAdminService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableAdminService.java new file mode 100644 index 00000000..760f94f7 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableAdminService.java @@ -0,0 +1,30 @@ +package com.bablsoft.accessflow.apigov.api; + +import java.util.List; +import java.util.UUID; + +/** + * Admin CRUD for per-connector dynamic variables (AF-613). All methods are organization-scoped: the + * connector must belong to {@code organizationId}, otherwise an {@link ApiConnectorNotFoundException} + * is thrown — so a cross-org id is indistinguishable from a missing one. + * + *

Validation is authoritative at save time, not execution time. Every mutation re-materializes + * the connector's full variable set with the candidate applied and rejects a dependency cycle, a + * reference to an unknown variable, or a duplicate name or injection target. {@link #delete} runs + * the inverse check and refuses to remove a variable another one still references. + */ +public interface ApiConnectorVariableAdminService { + + List listForConnector(UUID connectorId, UUID organizationId); + + ApiConnectorVariableView create(UUID connectorId, UUID organizationId, + CreateApiConnectorVariableCommand command); + + ApiConnectorVariableView update(UUID variableId, UUID connectorId, UUID organizationId, + UpdateApiConnectorVariableCommand command); + + void delete(UUID variableId, UUID connectorId, UUID organizationId); + + List reorder(UUID connectorId, UUID organizationId, + ReorderApiConnectorVariablesCommand command); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableLookupService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableLookupService.java new file mode 100644 index 00000000..afe0a556 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableLookupService.java @@ -0,0 +1,19 @@ +package com.bablsoft.accessflow.apigov.api; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +/** + * Submitter-facing reads over a connector's variables (AF-613) — what the request composer needs to + * offer overrides, and what the submit path needs to validate them. Carries no secret, no + * expression, and no algorithm. + */ +public interface ApiConnectorVariableLookupService { + + /** Every variable on the connector, projected to the submitter-safe fields. */ + List summariesForConnector(UUID connectorId, UUID organizationId); + + /** The names a submitter is allowed to override. Anything outside this set is rejected. */ + Set overridableNames(UUID connectorId, UUID organizationId); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableNotFoundException.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableNotFoundException.java new file mode 100644 index 00000000..56697037 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableNotFoundException.java @@ -0,0 +1,10 @@ +package com.bablsoft.accessflow.apigov.api; + +import java.util.UUID; + +public class ApiConnectorVariableNotFoundException extends ApiGovException { + + public ApiConnectorVariableNotFoundException(UUID variableId) { + super("API connector variable not found: " + variableId); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableResolutionService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableResolutionService.java new file mode 100644 index 00000000..303411ef --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableResolutionService.java @@ -0,0 +1,24 @@ +package com.bablsoft.accessflow.apigov.api; + +import java.util.Map; +import java.util.UUID; + +/** + * Evaluates a connector's dynamic variables for one outbound call (AF-613). + * + *

Called from the execution path after auth headers have been resolved — including a + * freshly minted OAuth2 bearer token — so an expression may sign the finished {@code Authorization} + * header. Variables are evaluated in dependency order over the DAG formed by their + * {@code {{var.x}}} references. + * + *

{@code overrides} carries per-request submitter-supplied values for variables marked + * overridable. An override replaces the value entirely — the kind, algorithm and + * encoding are not re-applied — and is inserted as an opaque literal that is never itself expanded + * as a template. Variables that depend on an overridden one still recompute over the new value. + * Names not marked overridable are ignored here; the submit path rejects them up front. + */ +public interface ApiConnectorVariableResolutionService { + + ResolvedApiVariables resolve(UUID organizationId, UUID connectorId, + ApiVariableRequestContext context, Map overrides); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableSummaryView.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableSummaryView.java new file mode 100644 index 00000000..7f259ce7 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableSummaryView.java @@ -0,0 +1,16 @@ +package com.bablsoft.accessflow.apigov.api; + +/** + * The submitter-visible projection of a connector variable (AF-613): enough to compose a request + * that references {@code {{name}}} and to offer an override, and nothing more. + * + *

Deliberately omits {@code expression}, {@code algorithm}, {@code encoding} and any hint of a + * stored secret — a submitter may reference a signature variable but must never learn how it is + * computed or what key backs it. + */ +public record ApiConnectorVariableSummaryView( + String name, + ApiVariableKind kind, + String description, + boolean overridable) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableView.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableView.java new file mode 100644 index 00000000..33ce8b26 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiConnectorVariableView.java @@ -0,0 +1,25 @@ +package com.bablsoft.accessflow.apigov.api; + +import java.time.Instant; +import java.util.UUID; + +/** + * Admin-facing view of a connector variable (AF-613). The stored secret is never exposed — only + * {@code hasSecret} reports whether one is configured, mirroring the connector credential rule. + */ +public record ApiConnectorVariableView( + UUID id, + UUID connectorId, + String name, + ApiVariableKind kind, + String expression, + ApiVariableAlgorithm algorithm, + ApiVariableEncoding encoding, + boolean hasSecret, + String target, + boolean overridable, + String description, + int sortOrder, + Instant createdAt, + Instant updatedAt) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiRequestView.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiRequestView.java index 6e604006..33988de4 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiRequestView.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiRequestView.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; /** @@ -35,6 +36,12 @@ public record ApiRequestView( Integer aiRiskScore, String aiSummary, ApiBodyType bodyType, + /** + * AF-613: the submitter-supplied variable overrides, so a reviewer approves exactly what + * will execute. These are the signing inputs; the resolved outputs (nonce, + * signature) are execution-time only and are never surfaced. + */ + Map variableOverrides, Instant scheduledFor, String traceId, String spanId, diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiReviewService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiReviewService.java index 3752e924..f704b690 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiReviewService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiReviewService.java @@ -33,6 +33,12 @@ record PendingApiReview( UUID apiRequestId, UUID connectorId, String connectorName, UUID submittedByUserId, String verb, String requestPath, boolean write, String justification, UUID aiAnalysisId, RiskLevel aiRiskLevel, Integer aiRiskScore, String aiSummary, int currentStage, + /** + * AF-613: how many connector variables this submitter overrode. A count rather than + * the values, so the queue can badge affected requests without joining the body; the + * values themselves are on the detail view. + */ + int variableOverrideCount, Instant createdAt) { } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableAlgorithm.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableAlgorithm.java new file mode 100644 index 00000000..40a79d3a --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableAlgorithm.java @@ -0,0 +1,16 @@ +package com.bablsoft.accessflow.apigov.api; + +/** + * Digest / MAC algorithm for a connector variable (AF-613). The HMAC values are valid only for + * {@link ApiVariableKind#HMAC} and the digest values only for {@link ApiVariableKind#HASH}; the + * admin service rejects any other pairing at save time. + * + *

{@code MD5} is retained solely for vendor contracts that still mandate it. It is not + * collision-resistant and must never be used for a signature. + */ +public enum ApiVariableAlgorithm { + HMAC_SHA256, + HMAC_SHA512, + SHA256, + MD5 +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableEncoding.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableEncoding.java new file mode 100644 index 00000000..faf56c28 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableEncoding.java @@ -0,0 +1,16 @@ +package com.bablsoft.accessflow.apigov.api; + +/** + * How a connector variable's raw bytes are rendered as text (AF-613). + */ +public enum ApiVariableEncoding { + + /** Lowercase hexadecimal. */ + HEX, + + /** Standard base64, padded. */ + BASE64, + + /** URL-safe base64, unpadded (the RFC 7515 shape most vendors expect). */ + BASE64URL +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableKind.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableKind.java new file mode 100644 index 00000000..533a6dd2 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableKind.java @@ -0,0 +1,36 @@ +package com.bablsoft.accessflow.apigov.api; + +/** + * How a connector variable's value is produced (AF-613). Every kind first renders its + * {@code expression} against the in-flight request and the already-resolved variables; the rendered + * string is then the kind's input. + */ +public enum ApiVariableKind { + + /** The rendered expression, verbatim. Encoding is ignored — use {@link #ENCODE} to re-encode. */ + CONSTANT, + + /** A fresh random UUID per evaluation. Expression must be blank. */ + UUID, + + /** + * The current instant truncated to seconds. A blank expression yields ISO-8601; otherwise the + * expression is a {@code DateTimeFormatter} pattern applied at UTC. + */ + TIMESTAMP, + + /** The current epoch millisecond count. Expression must be blank. */ + EPOCH_MILLIS, + + /** Cryptographically random bytes. Expression is an optional byte count (default 16, 1..256). */ + RANDOM_HEX, + + /** A digest of the rendered expression. Requires SHA256 or MD5. */ + HASH, + + /** A keyed MAC of the rendered expression. Requires an HMAC algorithm and a stored secret. */ + HMAC, + + /** Re-encodes the rendered expression's UTF-8 bytes. Requires an explicit encoding. */ + ENCODE +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableRequestContext.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableRequestContext.java new file mode 100644 index 00000000..a046f51f --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableRequestContext.java @@ -0,0 +1,38 @@ +package com.bablsoft.accessflow.apigov.api; + +import java.util.Map; + +/** + * The read-only view of the in-flight outbound request that connector variable expressions resolve + * against (AF-613), reachable as {@code {{request.method}}}, {@code {{request.path}}}, + * {@code {{request.query}}}, {@code {{request.body}}} and {@code {{request.headers.}}}. + * + *

Every field denotes the request as it stands before any variable substitution + * has happened. That is load-bearing, not incidental: the motivating vendor scheme signs a + * body that still contains the literal {@code {{signature}}} placeholder, and only substitutes the + * digest in afterwards. Resolving {@code request.body} post-substitution would compute a different + * digest and silently produce signatures the vendor rejects. + * + *

{@code headers} is the fully merged map — connector defaults, per-request headers, trace + * headers, and the auth header the applier just computed — so an expression may sign the finished + * {@code Authorization} value. Lookup by {@code {{request.headers.}}} is case-insensitive. + * + *

{@code query} is a canonical serialization (keys sorted, percent-encoded, joined with + * {@code &}) that is deliberately independent of the wire ordering, so a signature over it is + * reproducible. + */ +public record ApiVariableRequestContext( + String method, + String path, + String query, + String body, + Map headers) { + + public ApiVariableRequestContext { + method = method == null ? "" : method; + path = path == null ? "" : path; + query = query == null ? "" : query; + body = body == null ? "" : body; + headers = headers == null ? Map.of() : Map.copyOf(headers); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableTargetType.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableTargetType.java new file mode 100644 index 00000000..a9333167 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ApiVariableTargetType.java @@ -0,0 +1,14 @@ +package com.bablsoft.accessflow.apigov.api; + +/** + * Where a connector variable auto-injects its resolved value when no explicit {@code {{name}}} + * placeholder is used (AF-613). Encoded on the row as {@code "header:"} / {@code "query:"}. + * + *

There is deliberately no whole-body target: replacing an entire request body with one value is + * never what an operator means, and partial-body injection needs a JSON pointer — a separate + * feature. A body placeholder is spelled {@code {{name}}} in the body itself. + */ +public enum ApiVariableTargetType { + HEADER, + QUERY +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/CreateApiConnectorVariableCommand.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/CreateApiConnectorVariableCommand.java new file mode 100644 index 00000000..23a68aec --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/CreateApiConnectorVariableCommand.java @@ -0,0 +1,22 @@ +package com.bablsoft.accessflow.apigov.api; + +/** + * Command to create a connector variable (AF-613). Which fields are required, optional or forbidden + * depends on {@code kind} — see {@link ApiVariableKind}; the admin service rejects a bad combination + * with an {@link IllegalApiConnectorVariableException}. + * + *

{@code secret} is the raw shared key (required for {@code HMAC}, forbidden otherwise); it is + * AES-256-GCM encrypted before persistence and never read back. + */ +public record CreateApiConnectorVariableCommand( + String name, + ApiVariableKind kind, + String expression, + ApiVariableAlgorithm algorithm, + ApiVariableEncoding encoding, + String secret, + String target, + Boolean overridable, + String description, + Integer sortOrder) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/GrantApiConnectorGroupPermissionCommand.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/GrantApiConnectorGroupPermissionCommand.java index 86ed7063..f7b8c62a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/GrantApiConnectorGroupPermissionCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/GrantApiConnectorGroupPermissionCommand.java @@ -10,6 +10,7 @@ public record GrantApiConnectorGroupPermissionCommand( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/GrantApiConnectorPermissionCommand.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/GrantApiConnectorPermissionCommand.java index f8d25c06..687bcd26 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/GrantApiConnectorPermissionCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/GrantApiConnectorPermissionCommand.java @@ -10,6 +10,7 @@ public record GrantApiConnectorPermissionCommand( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/IllegalApiConnectorVariableException.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/IllegalApiConnectorVariableException.java new file mode 100644 index 00000000..42774f61 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/IllegalApiConnectorVariableException.java @@ -0,0 +1,16 @@ +package com.bablsoft.accessflow.apigov.api; + +/** + * A connector variable failed validation — a bad name, a field required or forbidden by its kind, an + * algorithm that does not match the kind, a malformed target, a duplicate name or target, a + * reference to an unknown variable, or a dependency cycle. The message is resolved at the throw site + * via {@code MessageSource} and surfaced verbatim as the 422 detail. + * + *

Messages must never embed a resolved value or a stored secret — only variable names. + */ +public class IllegalApiConnectorVariableException extends ApiGovException { + + public IllegalApiConnectorVariableException(String message) { + super(message); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ReorderApiConnectorVariablesCommand.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ReorderApiConnectorVariablesCommand.java new file mode 100644 index 00000000..57d8bcf9 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ReorderApiConnectorVariablesCommand.java @@ -0,0 +1,16 @@ +package com.bablsoft.accessflow.apigov.api; + +import java.util.List; +import java.util.UUID; + +/** + * Command to reassign {@code sort_order} across a connector's variables (AF-613). The list is the + * complete set of the connector's variable ids in the desired evaluation order; a partial or + * unknown-id list is rejected. + */ +public record ReorderApiConnectorVariablesCommand(List variableIds) { + + public ReorderApiConnectorVariablesCommand { + variableIds = variableIds == null ? List.of() : List.copyOf(variableIds); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ResolvedApiVariables.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ResolvedApiVariables.java new file mode 100644 index 00000000..c0151949 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/ResolvedApiVariables.java @@ -0,0 +1,42 @@ +package com.bablsoft.accessflow.apigov.api; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** + * The evaluated connector variables for one outbound call (AF-613): the value of each variable by + * name, plus the auto-injection targets that must be applied after substitution. + * + *

Resolved values are execution-time only. They are never persisted onto the request row, never + * written to the response snapshot, and never logged — the resolver cannot tell a signature (not + * sensitive) from a {@code CONSTANT} holding a shared secret (very much sensitive), so all of them + * are treated as sensitive. {@link #values()} exists so callers can scrub them out of any error + * message before it is persisted or logged. + */ +public record ResolvedApiVariables( + Map values, + List injections) { + + public ResolvedApiVariables { + values = values == null ? Map.of() : Map.copyOf(values); + injections = injections == null ? List.of() : List.copyOf(injections); + } + + public static ResolvedApiVariables empty() { + return new ResolvedApiVariables(Map.of(), List.of()); + } + + public boolean isEmpty() { + return values.isEmpty() && injections.isEmpty(); + } + + /** The resolved values, for redaction of outbound error text. Never render these to a user. */ + public Collection secretValues() { + return values.values(); + } + + /** An auto-injection of a resolved value into a header or query parameter. */ + public record ApiVariableInjection(ApiVariableTargetType type, String key, String value) { + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/SubmitApiRequestCommand.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/SubmitApiRequestCommand.java index 416533a9..3b86a722 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/SubmitApiRequestCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/SubmitApiRequestCommand.java @@ -28,6 +28,8 @@ public record SubmitApiRequestCommand( String requestBody, List formFields, String binaryFilename, + /** AF-613: per-request values for connector variables marked overridable. */ + Map variableOverrides, String justification, Instant scheduledFor, SubmissionReason submissionReason, diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorGroupPermissionCommand.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorGroupPermissionCommand.java index 1b244d5d..4de88bad 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorGroupPermissionCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorGroupPermissionCommand.java @@ -12,6 +12,7 @@ public record UpdateApiConnectorGroupPermissionCommand( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorPermissionCommand.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorPermissionCommand.java index 4e3c5bfb..af109602 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorPermissionCommand.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorPermissionCommand.java @@ -12,6 +12,7 @@ public record UpdateApiConnectorPermissionCommand( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorVariableCommand.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorVariableCommand.java new file mode 100644 index 00000000..76fc17fa --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/api/UpdateApiConnectorVariableCommand.java @@ -0,0 +1,23 @@ +package com.bablsoft.accessflow.apigov.api; + +/** + * Command to update a connector variable (AF-613). + * + *

{@code secret} is write-only and tri-state: {@code null} leaves the stored secret untouched, a + * non-blank value replaces it, and {@code clearSecret} removes it. That mirrors how connector auth + * credentials are edited — the current value is never sent to the client, so an unchanged field + * cannot be round-tripped. + */ +public record UpdateApiConnectorVariableCommand( + String name, + ApiVariableKind kind, + String expression, + ApiVariableAlgorithm algorithm, + ApiVariableEncoding encoding, + String secret, + Boolean clearSecret, + String target, + Boolean overridable, + String description, + Integer sortOrder) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiExecutionService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiExecutionService.java index e4afb3a9..b809d21a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiExecutionService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiExecutionService.java @@ -3,11 +3,14 @@ import com.bablsoft.accessflow.apigov.api.ApiAuthMethod; import com.bablsoft.accessflow.apigov.api.ApiBodyType; import com.bablsoft.accessflow.apigov.api.ApiConnectorMaskingResolutionService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableResolutionService; import com.bablsoft.accessflow.apigov.api.ApiExecutionException; import com.bablsoft.accessflow.apigov.api.ApiFormField; import com.bablsoft.accessflow.apigov.api.ApiInlineExecutionService; +import com.bablsoft.accessflow.apigov.api.ApiVariableRequestContext; import com.bablsoft.accessflow.apigov.api.IllegalApiRequestStateException; import com.bablsoft.accessflow.apigov.api.ResolvedApiMask; +import com.bablsoft.accessflow.apigov.api.ResolvedApiVariables; import com.bablsoft.accessflow.apigov.events.ApiRequestDecidedEvent; import com.bablsoft.accessflow.apigov.internal.client.ApiCallExecutor; import com.bablsoft.accessflow.apigov.internal.client.ApiCallRequest; @@ -64,6 +67,7 @@ public class ApiExecutionService implements ApiInlineExecutionService { private final ApiRequestStateService stateService; private final ApplicationEventPublisher eventPublisher; private final ObjectMapper objectMapper; + private final ApiConnectorVariableResolutionService variableResolutionService; private final ApigovRequestProperties requestProperties; @Transactional @@ -183,12 +187,49 @@ private void applyTraceHeaders(Map headers, ApiConnectorEntity c private ApiCallResult executeCall(ApiConnectorEntity connector, ApiRequestEntity request, Map headers) { var responseCap = Math.min(connector.getMaxResponseBytes(), requestProperties.maxResponseBytes()); - return executor.execute(new ApiCallRequest(connector.getProtocol(), connector.getBaseUrl(), + var raw = new ApiCallRequest(connector.getProtocol(), connector.getBaseUrl(), request.getVerb(), request.getRequestPath(), headers, readMap(request.getQueryParams()), request.getBodyType() == null ? ApiBodyType.RAW : request.getBodyType(), request.getRequestBody(), request.getRequestContentType(), readFormFields(request.getFormFields()), request.getBinaryFilename(), connector.getTimeoutMs(), responseCap, - request.getOperationId())); + request.getOperationId()); + + // AF-613. Variables resolve here rather than earlier because `headers` is already final at + // this point — connector defaults, per-request headers, trace headers and the auth header the + // applier just computed — so an expression may sign the finished Authorization value, which + // is what the motivating vendor scheme requires. The context deliberately describes the + // pre-substitution request: a body being signed must still contain its {{signature}} + // placeholder when the digest is taken. + var context = new ApiVariableRequestContext(raw.verb(), raw.path(), + ApiRequestVariableSubstitution.canonicalQuery(raw.queryParams()), + ApiRequestVariableSubstitution.bodyForContext(raw), raw.headers()); + var resolved = variableResolutionService.resolve(connector.getOrganizationId(), + connector.getId(), context, readMap(request.getVariableOverrides())); + var substituted = ApiRequestVariableSubstitution.apply(raw, resolved); + + try { + return executor.execute(substituted); + } catch (ApiExecutionException ex) { + // The upstream failure message can embed the substituted URI (the JDK's IOException does + // exactly that), and api_requests.error_message is persisted and shown to reviewers. Scrub + // every resolved value before it can escape — the resolver cannot tell a harmless + // signature from a CONSTANT holding a shared secret, so all of them are redacted. + throw new ApiExecutionException(redact(ex.getMessage(), resolved)); + } + } + + /** Replaces every resolved variable value in {@code message} with {@code ***}. */ + static String redact(String message, ResolvedApiVariables resolved) { + if (message == null || resolved == null) { + return message; + } + var scrubbed = message; + for (var value : resolved.secretValues()) { + if (value != null && !value.isBlank()) { + scrubbed = scrubbed.replace(value, "***"); + } + } + return scrubbed; } private List readFormFields(String json) { diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiRequestVariableSubstitution.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiRequestVariableSubstitution.java new file mode 100644 index 00000000..0a53db10 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiRequestVariableSubstitution.java @@ -0,0 +1,128 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiBodyType; +import com.bablsoft.accessflow.apigov.api.ApiFormField; +import com.bablsoft.accessflow.apigov.api.ApiVariableTargetType; +import com.bablsoft.accessflow.apigov.api.ResolvedApiVariables; +import com.bablsoft.accessflow.apigov.internal.client.ApiCallRequest; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.TreeMap; + +/** + * Applies resolved connector variables to a composed outbound call (AF-613): substitutes + * {@code {{name}}} placeholders, then applies any {@code header:} / {@code query:} auto-injections. + * + *

What is deliberately not substituted matters as much as what is: + *

    + *
  • Header names and query keys — a variable-named header could not be meaningfully + * reviewed, since the reviewer would not know what the request will actually send.
  • + *
  • {@code baseUrl} — a variable-controlled host turns a governed call into an SSRF + * pivot. The target of a connector is admin config and stays fixed.
  • + *
  • Binary bodies and {@code FILE} form parts — those are base64; substituting inside + * them would corrupt the payload rather than template it.
  • + *
+ */ +final class ApiRequestVariableSubstitution { + + private ApiRequestVariableSubstitution() { + } + + /** The canonical {@code {{request.query}}} serialization: sorted, percent-encoded, {@code &}-joined. */ + static String canonicalQuery(Map queryParams) { + if (queryParams == null || queryParams.isEmpty()) { + return ""; + } + // Sorted rather than wire order, so a signature over it reproduces regardless of how the map + // was built. The executor's own URL assembly may emit a different order; that is intentional + // and documented — the canonical form exists for signing, not for transport. + var sorted = new TreeMap<>(queryParams); + var out = new StringBuilder(); + sorted.forEach((key, value) -> { + if (!out.isEmpty()) { + out.append('&'); + } + out.append(URLEncoder.encode(key, StandardCharsets.UTF_8)) + .append('=') + .append(URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8)); + }); + return out.toString(); + } + + /** + * The body as {@code {{request.body}}} sees it. {@code FORM_DATA} yields the empty string: + * multipart boundaries are randomly generated per send, so no signature over the assembled + * multipart payload could ever be reproduced. + */ + static String bodyForContext(ApiCallRequest request) { + var bodyType = request.bodyType() == null ? ApiBodyType.RAW : request.bodyType(); + return switch (bodyType) { + case RAW, BINARY -> request.body() == null ? "" : request.body(); + case FORM_URLENCODED -> canonicalQuery(formFieldsAsMap(request)); + case NONE, FORM_DATA -> ""; + }; + } + + private static Map formFieldsAsMap(ApiCallRequest request) { + var map = new LinkedHashMap(); + if (request.formFields() != null) { + request.formFields().forEach(f -> map.put(f.key(), f.value())); + } + return map; + } + + /** Returns {@code request} with placeholders substituted and injections applied. */ + static ApiCallRequest apply(ApiCallRequest request, ResolvedApiVariables resolved) { + if (resolved == null || resolved.isEmpty()) { + return request; + } + + var headers = new LinkedHashMap(); + request.headers().forEach((name, value) -> headers.put(name, substitute(value, resolved))); + + var queryParams = new LinkedHashMap(); + request.queryParams().forEach((key, value) -> queryParams.put(key, substitute(value, resolved))); + + for (var injection : resolved.injections()) { + if (injection.type() == ApiVariableTargetType.HEADER) { + headers.put(injection.key(), injection.value()); + } else { + queryParams.put(injection.key(), injection.value()); + } + } + + var bodyType = request.bodyType() == null ? ApiBodyType.RAW : request.bodyType(); + // BINARY bodies are base64 — substituting inside one would corrupt it, not template it. + var body = bodyType == ApiBodyType.RAW ? substitute(request.body(), resolved) : request.body(); + + var formFields = new ArrayList(); + if (request.formFields() != null) { + for (var field : request.formFields()) { + formFields.add(field.type() == ApiFormField.ApiFormFieldType.TEXT + ? new ApiFormField(field.key(), field.type(), substitute(field.value(), resolved), + field.filename(), field.contentType()) + : field); + } + } + + return new ApiCallRequest(request.protocol(), request.baseUrl(), request.verb(), + substitute(request.path(), resolved), headers, queryParams, request.bodyType(), body, + request.contentType(), formFields, request.binaryFilename(), request.timeoutMs(), + request.maxResponseBytes(), request.operationId()); + } + + /** + * Single-pass substitution against the resolved values. {@code {{request.*}}} has no meaning + * here — there is no expression being evaluated — so such a reference resolves to nothing and is + * left literal. + */ + private static String substitute(String template, ResolvedApiVariables resolved) { + return ApiVariableTemplate.render(template, + ref -> ref.isVariable() ? resolved.values().get(ref.key()) : null, + ApiVariableTemplate.SUBSTITUTION_STRICT_SCOPES); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluationException.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluationException.java new file mode 100644 index 00000000..ee57d4ca --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluationException.java @@ -0,0 +1,36 @@ +package com.bablsoft.accessflow.apigov.internal; + +import java.io.Serial; +import java.util.Arrays; +import java.util.List; + +/** + * A connector variable could not be evaluated (AF-613). Carries an i18n message key and its + * arguments rather than a rendered string, so the same failure can surface as a save-time 422 or an + * execution-time error in the caller's locale. + * + *

Arguments must only ever be variable names, algorithm names or numeric limits — never an + * expression, a resolved value, or a secret. + */ +class ApiVariableEvaluationException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final transient List args; + private final String messageKey; + + ApiVariableEvaluationException(String messageKey, Object... args) { + super(messageKey); + this.messageKey = messageKey; + this.args = List.copyOf(Arrays.asList(args)); + } + + String messageKey() { + return messageKey; + } + + Object[] args() { + return args.toArray(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluator.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluator.java new file mode 100644 index 00000000..3ee0fadc --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluator.java @@ -0,0 +1,149 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Clock; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.HexFormat; +import java.util.UUID; + +/** + * Produces the value of one connector variable from its already-rendered input (AF-613). + * + *

Pure with respect to configuration — the only ambient inputs are the clock and a secure random + * source, both injected so the time- and randomness-dependent kinds are testable. There is no + * scripting engine and no expression language here: a variable is a fixed function over a template + * substitution, deliberately mirroring how the engine plugins reject server-side scripting + * ({@code $where}, Painless, CQL UDFs). + */ +@Component +class ApiVariableEvaluator { + + private static final int DEFAULT_RANDOM_BYTES = 16; + private static final int MIN_RANDOM_BYTES = 1; + private static final int MAX_RANDOM_BYTES = 256; + + private final Clock clock; + private final SecureRandom secureRandom = new SecureRandom(); + + ApiVariableEvaluator(Clock clock) { + this.clock = clock; + } + + /** + * @param name the variable name, used only for error reporting + * @param input the expression after template rendering + * @param secret the decrypted shared key, for {@link ApiVariableKind#HMAC} only + */ + String evaluate(String name, ApiVariableKind kind, ApiVariableAlgorithm algorithm, + ApiVariableEncoding encoding, String input, String secret) { + return switch (kind) { + case CONSTANT -> input == null ? "" : input; + case UUID -> UUID.randomUUID().toString(); + case TIMESTAMP -> timestamp(name, input); + case EPOCH_MILLIS -> Long.toString(clock.instant().toEpochMilli()); + case RANDOM_HEX -> randomBytes(name, input, encoding); + case HASH -> encode(digest(name, algorithm, input), encoding, ApiVariableEncoding.HEX); + case HMAC -> encode(mac(name, algorithm, input, secret), encoding, ApiVariableEncoding.HEX); + case ENCODE -> encode(utf8(input), encoding, ApiVariableEncoding.BASE64); + }; + } + + private String timestamp(String name, String pattern) { + var now = clock.instant().truncatedTo(ChronoUnit.SECONDS); + if (pattern == null || pattern.isBlank()) { + return DateTimeFormatter.ISO_INSTANT.format(now); + } + try { + return DateTimeFormatter.ofPattern(pattern).withZone(ZoneOffset.UTC).format(now); + } catch (IllegalArgumentException | java.time.DateTimeException ex) { + throw new ApiVariableEvaluationException("error.api_connector_variable_timestamp_pattern", name); + } + } + + private String randomBytes(String name, String size, ApiVariableEncoding encoding) { + var count = DEFAULT_RANDOM_BYTES; + if (size != null && !size.isBlank()) { + try { + count = Integer.parseInt(size.trim()); + } catch (NumberFormatException ex) { + throw new ApiVariableEvaluationException("error.api_connector_variable_random_hex_size", + name, MIN_RANDOM_BYTES, MAX_RANDOM_BYTES); + } + } + if (count < MIN_RANDOM_BYTES || count > MAX_RANDOM_BYTES) { + throw new ApiVariableEvaluationException("error.api_connector_variable_random_hex_size", + name, MIN_RANDOM_BYTES, MAX_RANDOM_BYTES); + } + var bytes = new byte[count]; + secureRandom.nextBytes(bytes); + return encode(bytes, encoding, ApiVariableEncoding.HEX); + } + + private byte[] digest(String name, ApiVariableAlgorithm algorithm, String input) { + // MD5 is offered only for legacy vendor contracts that mandate it; it is rejected for HMAC + // in the admin service, so it can never back a signature here. + var jca = switch (algorithm) { + case SHA256 -> "SHA-256"; + case MD5 -> "MD5"; + case HMAC_SHA256, HMAC_SHA512 -> null; + case null -> null; + }; + if (jca == null) { + throw new ApiVariableEvaluationException("error.api_connector_variable_algorithm_invalid", name); + } + try { + return MessageDigest.getInstance(jca).digest(utf8(input)); + } catch (NoSuchAlgorithmException ex) { + throw new ApiVariableEvaluationException("error.api_connector_variable_algorithm_invalid", name); + } + } + + private byte[] mac(String name, ApiVariableAlgorithm algorithm, String input, String secret) { + var jca = switch (algorithm) { + case HMAC_SHA256 -> "HmacSHA256"; + case HMAC_SHA512 -> "HmacSHA512"; + case SHA256, MD5 -> null; + case null -> null; + }; + if (jca == null) { + throw new ApiVariableEvaluationException("error.api_connector_variable_algorithm_invalid", name); + } + if (secret == null || secret.isEmpty()) { + throw new ApiVariableEvaluationException("error.api_connector_variable_secret_required", name); + } + try { + var mac = Mac.getInstance(jca); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), jca)); + return mac.doFinal(utf8(input)); + } catch (GeneralSecurityException ex) { + throw new ApiVariableEvaluationException("error.api_connector_variable_algorithm_invalid", name); + } + } + + private static String encode(byte[] bytes, ApiVariableEncoding encoding, ApiVariableEncoding fallback) { + return switch (encoding == null ? fallback : encoding) { + case HEX -> HexFormat.of().formatHex(bytes); + case BASE64 -> Base64.getEncoder().encodeToString(bytes); + // Unpadded, matching RFC 7515 — the shape vendors actually specify. + case BASE64URL -> Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + }; + } + + private static byte[] utf8(String value) { + return (value == null ? "" : value).getBytes(StandardCharsets.UTF_8); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableGraph.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableGraph.java new file mode 100644 index 00000000..37dfdcdd --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableGraph.java @@ -0,0 +1,143 @@ +package com.bablsoft.accessflow.apigov.internal; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.PriorityQueue; +import java.util.Set; + +/** + * Dependency ordering over a connector's dynamic variables (AF-613). + * + *

A variable's expression may reference other variables, so evaluation must follow a topological + * order of the resulting DAG. Kahn's algorithm drives it, with the ready set kept in a priority + * queue keyed by the caller's input index — the repository already returns rows in + * {@code (sort_order, created_at, id)} order, a total order, so independent variables evaluate in a + * stable, operator-controlled sequence rather than whatever order the hash iteration happened to + * produce. That matters because evaluation order is observable: two {@code TIMESTAMP} variables + * resolved in a different order can produce different values. + * + *

Cycles are a configuration error caught at save time, not a runtime failure. The resolver runs + * the same sort defensively at execution time; if it ever fires there, the config was mutated + * outside the admin service. + */ +final class ApiVariableGraph { + + private ApiVariableGraph() { + } + + /** One variable as the graph sees it: a name and the expression whose references form its edges. */ + record Node(String name, String expression) { + } + + /** A dependency cycle. Carries the participating names — never an expression or a value. */ + static final class CycleException extends RuntimeException { + + private final transient List names; + + CycleException(List names) { + super("Cyclic variable references: " + String.join(", ", names)); + this.names = List.copyOf(names); + } + + List names() { + return names; + } + } + + /** A strict {@code {{var.x}}} reference to a variable that does not exist on the connector. */ + static final class UnknownReferenceException extends RuntimeException { + + private final transient String from; + private final transient String missing; + + UnknownReferenceException(String from, String missing) { + super("Variable '" + from + "' references unknown variable '" + missing + "'"); + this.from = from; + this.missing = missing; + } + + String from() { + return from; + } + + String missing() { + return missing; + } + } + + /** + * Orders {@code nodes} so that every variable comes after the ones it references. + * + * @param nodes the connector's variables, already in {@code (sort_order, created_at, id)} order + * @return the same nodes in evaluation order + * @throws UnknownReferenceException on a strict reference to a name not in {@code nodes} + * @throws CycleException when the references do not form a DAG + */ + static List evaluationOrder(List nodes) { + var indexByName = new LinkedHashMap(); + for (var i = 0; i < nodes.size(); i++) { + indexByName.put(nodes.get(i).name(), i); + } + + // dependencies[i] = indices this node must wait for; dependents[i] = indices waiting on it. + var dependencies = new ArrayList>(nodes.size()); + var dependents = new ArrayList>(nodes.size()); + for (var i = 0; i < nodes.size(); i++) { + dependencies.add(new LinkedHashSet<>()); + dependents.add(new ArrayList<>()); + } + + for (var i = 0; i < nodes.size(); i++) { + var node = nodes.get(i); + for (var missing : ApiVariableTemplate.strictVariableReferences(node.expression())) { + if (!indexByName.containsKey(missing)) { + throw new UnknownReferenceException(node.name(), missing); + } + } + for (var referenced : ApiVariableTemplate.variableReferences(node.expression())) { + var target = indexByName.get(referenced); + // A bare reference to a non-existent name stays literal at render time, so it is not + // an edge. A self-reference is a one-node cycle and is reported as such. + if (target == null) { + continue; + } + if (dependencies.get(i).add(target)) { + dependents.get(target).add(i); + } + } + } + + var remaining = new int[nodes.size()]; + var ready = new PriorityQueue(); + for (var i = 0; i < nodes.size(); i++) { + remaining[i] = dependencies.get(i).size(); + if (remaining[i] == 0) { + ready.add(i); + } + } + + var ordered = new ArrayList(nodes.size()); + while (!ready.isEmpty()) { + var i = ready.poll(); + ordered.add(nodes.get(i)); + for (var dependent : dependents.get(i)) { + if (--remaining[dependent] == 0) { + ready.add(dependent); + } + } + } + + if (ordered.size() != nodes.size()) { + var stuck = new ArrayList(); + for (var i = 0; i < nodes.size(); i++) { + if (remaining[i] > 0) { + stuck.add(nodes.get(i).name()); + } + } + throw new CycleException(stuck); + } + return ordered; + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTargets.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTargets.java new file mode 100644 index 00000000..8b2bfc54 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTargets.java @@ -0,0 +1,50 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiVariableTargetType; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * Parsing for a connector variable's optional auto-injection {@code target} (AF-613), stored as + * {@code "header:"} or {@code "query:"}. + * + *

The header-name character class is RFC 7230's {@code token}, so a target can never smuggle a + * separator or control character into the header block. + */ +final class ApiVariableTargets { + + private static final Pattern TARGET = + Pattern.compile("^(header|query):([A-Za-z0-9!#$%&'*+\\-.^_`|~]{1,128})$"); + + private ApiVariableTargets() { + } + + record Target(ApiVariableTargetType type, String key) { + } + + /** Returns {@code null} for a blank target (the common case — no auto-injection). */ + static Target parse(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + var matcher = TARGET.matcher(raw.trim()); + if (!matcher.matches()) { + return null; + } + var type = "header".equals(matcher.group(1).toLowerCase(Locale.ROOT)) + ? ApiVariableTargetType.HEADER + : ApiVariableTargetType.QUERY; + return new Target(type, matcher.group(2)); + } + + /** True when {@code raw} is blank or a well-formed target. Used by save-time validation. */ + static boolean isValid(String raw) { + return raw == null || raw.isBlank() || TARGET.matcher(raw.trim()).matches(); + } + + /** The canonical stored form, or {@code null} when blank. */ + static String normalize(String raw) { + return raw == null || raw.isBlank() ? null : raw.trim(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTemplate.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTemplate.java new file mode 100644 index 00000000..f2fdd8d4 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTemplate.java @@ -0,0 +1,182 @@ +package com.bablsoft.accessflow.apigov.internal; + +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The {@code {{...}}} placeholder grammar for connector dynamic variables (AF-613). + * + *

Three reference forms, differing in how an unresolved one is treated: + *

    + *
  • {@code {{name}}} — a connector variable, lenient: left literal when unknown. + * Request bodies legitimately contain braces from unrelated templating (Handlebars, Jinja, + * vendor payload templates), and erroring on every unmatched pair would break real + * submissions.
  • + *
  • {@code {{var.name}}} — the same variable, strict: an unknown name is an error. This + * is the fail-fast spelling for authors who want a typo caught rather than sent.
  • + *
  • {@code {{request.*}}} — the evaluation context, strict, and only meaningful inside a + * variable's expression. At a substitution site (a header, path, query or body) there is no + * request scope, so such a reference is left literal.
  • + *
+ * + *

Rendering is single-pass and non-recursive. A substituted value is never + * re-scanned for further placeholders. This is the primary containment property for per-request + * overrides: a submitter-supplied override of {@code "{{apiKey}}"} stays those eleven literal + * characters and can never expand into the value of a secret-bearing variable. It is a security + * boundary, not a performance choice — see {@code ApiVariableTemplateTest}. + */ +final class ApiVariableTemplate { + + /** + * A name is a leading letter plus letters/digits/underscore, capped at 64 — deliberately + * dot-free so a variable can never shadow the {@code request.} or {@code var.} namespace. + */ + static final Pattern NAME_PATTERN = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,63}$"); + + private static final Pattern PLACEHOLDER = + Pattern.compile("\\{\\{\\s*([A-Za-z_][A-Za-z0-9_.\\-]*)\\s*}}"); + + private static final String VAR_PREFIX = "var."; + private static final String REQUEST_PREFIX = "request."; + + private ApiVariableTemplate() { + } + + /** Which namespace a reference addresses, and therefore whether it resolves strictly. */ + enum Scope { + /** {@code {{name}}} — a variable, resolved leniently. */ + VARIABLE_BARE, + /** {@code {{var.name}}} — a variable, resolved strictly. */ + VARIABLE_QUALIFIED, + /** {@code {{request.x}}} — the evaluation context. */ + REQUEST, + /** Some other dotted token; never resolved, always left literal. */ + FOREIGN + } + + /** + * One parsed placeholder. {@code key} is the reference with its namespace prefix stripped, so + * {@code {{name}}} and {@code {{var.name}}} both carry key {@code "name"}. + */ + record Reference(String raw, Scope scope, String key) { + + boolean isVariable() { + return scope == Scope.VARIABLE_BARE || scope == Scope.VARIABLE_QUALIFIED; + } + } + + /** Signals a strict reference that could not be resolved. Carries the reference, never a value. */ + static final class UnresolvedReferenceException extends RuntimeException { + + private final transient Reference reference; + + UnresolvedReferenceException(Reference reference) { + super("Unresolved template reference: " + reference.raw()); + this.reference = reference; + } + + Reference reference() { + return reference; + } + } + + private static Reference parse(String raw) { + if (raw.startsWith(VAR_PREFIX)) { + return new Reference(raw, Scope.VARIABLE_QUALIFIED, raw.substring(VAR_PREFIX.length())); + } + if (raw.startsWith(REQUEST_PREFIX)) { + return new Reference(raw, Scope.REQUEST, raw.substring(REQUEST_PREFIX.length())); + } + return raw.indexOf('.') >= 0 + ? new Reference(raw, Scope.FOREIGN, raw) + : new Reference(raw, Scope.VARIABLE_BARE, raw); + } + + /** The distinct variable names a template references, in first-appearance order. */ + static Set variableReferences(String template) { + var names = new LinkedHashSet(); + if (template == null || template.isEmpty()) { + return names; + } + var matcher = PLACEHOLDER.matcher(template); + while (matcher.find()) { + var ref = parse(matcher.group(1)); + if (ref.isVariable()) { + names.add(ref.key()); + } + } + return names; + } + + /** + * The distinct variable names referenced in the strict {@code {{var.name}}} form. An unknown + * name here is a configuration error; an unknown bare {@code {{name}}} is not. + */ + static Set strictVariableReferences(String template) { + var names = new LinkedHashSet(); + if (template == null || template.isEmpty()) { + return names; + } + var matcher = PLACEHOLDER.matcher(template); + while (matcher.find()) { + var ref = parse(matcher.group(1)); + if (ref.scope() == Scope.VARIABLE_QUALIFIED) { + names.add(ref.key()); + } + } + return names; + } + + /** + * The scopes that are strict when rendering a variable's expression: both qualified + * forms must resolve, since an expression is admin-authored configuration and a typo there is a + * bug worth surfacing at save time. + */ + static final Set EXPRESSION_STRICT_SCOPES = + Set.of(Scope.VARIABLE_QUALIFIED, Scope.REQUEST); + + /** + * The scopes that are strict when rendering a substitution site (a header value, path, + * query value or body). {@code {{request.*}}} is not strict here: there is no expression being + * evaluated, so the reference is meaningless rather than wrong, and a body may well contain such + * text for reasons of its own. + */ + static final Set SUBSTITUTION_STRICT_SCOPES = Set.of(Scope.VARIABLE_QUALIFIED); + + /** + * Renders {@code template}, replacing each resolvable placeholder exactly once. + * + * @param resolver returns the value for a reference, or {@code null} when it cannot supply + * one. A resolver with no request context simply returns {@code null} for + * {@link Scope#REQUEST} references. + * @param strictScopes scopes for which an unresolved reference throws rather than staying + * literal. {@link Scope#VARIABLE_BARE} and {@link Scope#FOREIGN} are always + * lenient, whatever this contains. + * @throws UnresolvedReferenceException for an unresolved reference in a strict scope + */ + static String render(String template, Function resolver, Set strictScopes) { + if (template == null || template.isEmpty()) { + return template; + } + var matcher = PLACEHOLDER.matcher(template); + var out = new StringBuilder(template.length()); + while (matcher.find()) { + var ref = parse(matcher.group(1)); + var value = ref.scope() == Scope.FOREIGN ? null : resolver.apply(ref); + if (value == null) { + if (strictScopes.contains(ref.scope())) { + throw new UnresolvedReferenceException(ref); + } + // Lenient: emit the placeholder untouched so unrelated templating survives. + matcher.appendReplacement(out, Matcher.quoteReplacement(matcher.group())); + continue; + } + matcher.appendReplacement(out, Matcher.quoteReplacement(value)); + } + matcher.appendTail(out); + return out.toString(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorAdminService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorAdminService.java index 99fab884..fff854d9 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorAdminService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorAdminService.java @@ -305,6 +305,7 @@ public ApiConnectorPermissionView grantPermission(UUID connectorId, UUID organiz entity.setCanRead(command.canRead()); entity.setCanWrite(command.canWrite()); entity.setCanBreakGlass(command.canBreakGlass()); + entity.setCanOverrideVariables(command.canOverrideVariables()); entity.setExpiresAt(command.expiresAt()); entity.setAllowedOperations(toArray(command.allowedOperations())); entity.setRestrictedResponseFields(toArray(command.restrictedResponseFields())); @@ -323,6 +324,7 @@ public ApiConnectorPermissionView updatePermission(UUID connectorId, UUID organi entity.setCanRead(command.canRead()); entity.setCanWrite(command.canWrite()); entity.setCanBreakGlass(command.canBreakGlass()); + entity.setCanOverrideVariables(command.canOverrideVariables()); entity.setExpiresAt(command.expiresAt()); entity.setAllowedOperations(toArray(command.allowedOperations())); entity.setRestrictedResponseFields(toArray(command.restrictedResponseFields())); @@ -370,6 +372,7 @@ public ApiConnectorGroupPermissionView grantGroupPermission( entity.setCanRead(command.canRead()); entity.setCanWrite(command.canWrite()); entity.setCanBreakGlass(command.canBreakGlass()); + entity.setCanOverrideVariables(command.canOverrideVariables()); entity.setExpiresAt(command.expiresAt()); entity.setAllowedOperations(toArray(command.allowedOperations())); entity.setRestrictedResponseFields(toArray(command.restrictedResponseFields())); @@ -388,6 +391,7 @@ public ApiConnectorGroupPermissionView updateGroupPermission( entity.setCanRead(command.canRead()); entity.setCanWrite(command.canWrite()); entity.setCanBreakGlass(command.canBreakGlass()); + entity.setCanOverrideVariables(command.canOverrideVariables()); entity.setExpiresAt(command.expiresAt()); entity.setAllowedOperations(toArray(command.allowedOperations())); entity.setRestrictedResponseFields(toArray(command.restrictedResponseFields())); @@ -453,7 +457,8 @@ private ApiConnectorPermissionView toPermissionView(ApiConnectorUserPermissionEn e.getId(), e.getConnectorId(), e.getUserId(), user != null ? user.email() : null, user != null ? user.displayName() : null, - e.isCanRead(), e.isCanWrite(), e.isCanBreakGlass(), e.getExpiresAt(), + e.isCanRead(), e.isCanWrite(), e.isCanBreakGlass(), e.isCanOverrideVariables(), + e.getExpiresAt(), e.getAllowedOperations() != null ? List.of(e.getAllowedOperations()) : List.of(), e.getRestrictedResponseFields() != null ? List.of(e.getRestrictedResponseFields()) : List.of(), e.getCreatedAt()); @@ -470,7 +475,8 @@ private ApiConnectorGroupPermissionView toGroupPermissionView(ApiConnectorGroupP e.getId(), e.getConnectorId(), e.getGroupId(), group != null ? group.name() : null, group != null ? group.memberCount() : 0, - e.isCanRead(), e.isCanWrite(), e.isCanBreakGlass(), e.getExpiresAt(), + e.isCanRead(), e.isCanWrite(), e.isCanBreakGlass(), e.isCanOverrideVariables(), + e.getExpiresAt(), e.getAllowedOperations() != null ? List.of(e.getAllowedOperations()) : List.of(), e.getRestrictedResponseFields() != null ? List.of(e.getRestrictedResponseFields()) : List.of(), e.getCreatedAt()); diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorPermissionLookupService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorPermissionLookupService.java index d8869dea..48521d84 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorPermissionLookupService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorPermissionLookupService.java @@ -24,6 +24,7 @@ public Optional findFor(UUID connectorId, UUID p.canRead(), p.canWrite(), p.canBreakGlass(), + p.canOverrideVariables(), p.allowedOperations(), p.expiresAt())); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableAdminService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableAdminService.java new file mode 100644 index 00000000..03c605c8 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableAdminService.java @@ -0,0 +1,359 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiConnectorNotFoundException; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableAdminService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableNotFoundException; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableView; +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import com.bablsoft.accessflow.apigov.api.CreateApiConnectorVariableCommand; +import com.bablsoft.accessflow.apigov.api.IllegalApiConnectorVariableException; +import com.bablsoft.accessflow.apigov.api.ReorderApiConnectorVariablesCommand; +import com.bablsoft.accessflow.apigov.api.UpdateApiConnectorVariableCommand; +import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiConnectorVariableEntity; +import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorRepository; +import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorVariableRepository; +import com.bablsoft.accessflow.core.api.CredentialEncryptionService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Admin CRUD for connector dynamic variables (AF-613). + * + *

Validation is authoritative here rather than at execution time. Every mutation re-materializes + * the connector's whole variable set with the candidate applied and re-runs the dependency sort, so + * a cycle or a dangling reference is a 422 the operator sees while editing — not a failed call hours + * later, after a reviewer has already approved the request. + */ +@Service +@RequiredArgsConstructor +class DefaultApiConnectorVariableAdminService implements ApiConnectorVariableAdminService { + + private static final int MAX_VARIABLES_PER_CONNECTOR = 64; + + private final ApiConnectorVariableRepository variableRepository; + private final ApiConnectorRepository connectorRepository; + private final CredentialEncryptionService encryptionService; + private final MessageSource messageSource; + + @Override + @Transactional(readOnly = true) + public List listForConnector(UUID connectorId, UUID organizationId) { + requireConnectorInOrganization(connectorId, organizationId); + return load(connectorId, organizationId).stream().map(this::toView).toList(); + } + + @Override + @Transactional + public ApiConnectorVariableView create(UUID connectorId, UUID organizationId, + CreateApiConnectorVariableCommand command) { + requireConnectorInOrganization(connectorId, organizationId); + var existing = load(connectorId, organizationId); + if (existing.size() >= MAX_VARIABLES_PER_CONNECTOR) { + throw illegal("error.api_connector_variable_too_many", MAX_VARIABLES_PER_CONNECTOR); + } + + var entity = new ApiConnectorVariableEntity(); + entity.setId(UUID.randomUUID()); + entity.setOrganizationId(organizationId); + entity.setConnectorId(connectorId); + entity.setName(requireName(command.name())); + entity.setKind(requireKind(command.kind())); + entity.setExpression(blankToNull(command.expression())); + entity.setAlgorithm(command.algorithm()); + entity.setEncoding(command.encoding()); + entity.setSecretEncrypted(encryptSecret(command.secret())); + entity.setTarget(ApiVariableTargets.normalize(command.target())); + entity.setOverridable(Boolean.TRUE.equals(command.overridable())); + entity.setDescription(blankToNull(command.description())); + entity.setSortOrder(command.sortOrder() == null ? nextSortOrder(existing) : command.sortOrder()); + + validate(entity, existing); + return toView(variableRepository.save(entity)); + } + + @Override + @Transactional + public ApiConnectorVariableView update(UUID variableId, UUID connectorId, UUID organizationId, + UpdateApiConnectorVariableCommand command) { + requireConnectorInOrganization(connectorId, organizationId); + var entity = loadInScope(variableId, connectorId, organizationId); + var previousName = entity.getName(); + + entity.setName(requireName(command.name())); + entity.setKind(requireKind(command.kind())); + entity.setExpression(blankToNull(command.expression())); + entity.setAlgorithm(command.algorithm()); + entity.setEncoding(command.encoding()); + entity.setTarget(ApiVariableTargets.normalize(command.target())); + entity.setOverridable(Boolean.TRUE.equals(command.overridable())); + entity.setDescription(blankToNull(command.description())); + if (command.sortOrder() != null) { + entity.setSortOrder(command.sortOrder()); + } + // The stored secret is never sent to the client, so an unchanged field cannot be + // round-tripped: null means "leave it", an explicit clear removes it. + if (Boolean.TRUE.equals(command.clearSecret())) { + entity.setSecretEncrypted(null); + } else if (command.secret() != null && !command.secret().isBlank()) { + entity.setSecretEncrypted(encryptSecret(command.secret())); + } + + var others = load(connectorId, organizationId).stream() + .filter(e -> !e.getId().equals(variableId)) + .toList(); + // A rename orphans every reference to the old name, so it is validated like a delete. + if (!previousName.equals(entity.getName())) { + requireNotReferenced(previousName, others); + } + validate(entity, others); + return toView(variableRepository.save(entity)); + } + + @Override + @Transactional + public void delete(UUID variableId, UUID connectorId, UUID organizationId) { + requireConnectorInOrganization(connectorId, organizationId); + var entity = loadInScope(variableId, connectorId, organizationId); + var survivors = load(connectorId, organizationId).stream() + .filter(e -> !e.getId().equals(variableId)) + .toList(); + requireNotReferenced(entity.getName(), survivors); + variableRepository.delete(entity); + } + + @Override + @Transactional + public List reorder(UUID connectorId, UUID organizationId, + ReorderApiConnectorVariablesCommand command) { + requireConnectorInOrganization(connectorId, organizationId); + var existing = load(connectorId, organizationId); + var requested = command.variableIds(); + // The list must be the complete set: a partial reorder would leave the rest at stale + // positions, which is exactly the kind of silent surprise evaluation order must not have. + if (requested.size() != existing.size() + || !new HashSet<>(requested).equals(existing.stream() + .map(ApiConnectorVariableEntity::getId).collect(Collectors.toSet()))) { + throw illegal("error.api_connector_variable_reorder_incomplete"); + } + for (var i = 0; i < requested.size(); i++) { + var id = requested.get(i); + var entity = existing.stream().filter(e -> e.getId().equals(id)).findFirst().orElseThrow(); + entity.setSortOrder(i); + variableRepository.save(entity); + } + return load(connectorId, organizationId).stream().map(this::toView).toList(); + } + + // --- validation --------------------------------------------------------------------------- + + /** Validates {@code candidate} both on its own and against the rest of the connector's set. */ + private void validate(ApiConnectorVariableEntity candidate, List others) { + validateKindFields(candidate); + validateOverridable(candidate); + validateTarget(candidate, others); + + for (var other : others) { + if (other.getName().equals(candidate.getName())) { + throw illegal("error.api_connector_variable_name_duplicate", candidate.getName()); + } + } + + var combined = new ArrayList<>(others); + combined.add(candidate); + combined.sort(Comparator.comparingInt(ApiConnectorVariableEntity::getSortOrder) + .thenComparing(ApiConnectorVariableEntity::getName)); + var nodes = combined.stream() + .map(e -> new ApiVariableGraph.Node(e.getName(), e.getExpression())) + .toList(); + try { + ApiVariableGraph.evaluationOrder(nodes); + } catch (ApiVariableGraph.CycleException ex) { + throw illegal("error.api_connector_variable_cycle", String.join(", ", ex.names())); + } catch (ApiVariableGraph.UnknownReferenceException ex) { + throw illegal("error.api_connector_variable_unknown_reference", ex.from(), ex.missing()); + } + } + + private void validateKindFields(ApiConnectorVariableEntity e) { + var name = e.getName(); + var hasExpression = e.getExpression() != null && !e.getExpression().isBlank(); + + switch (e.getKind()) { + case UUID, EPOCH_MILLIS -> { + if (hasExpression) { + throw illegal("error.api_connector_variable_expression_forbidden", name); + } + } + case CONSTANT, HASH, HMAC, ENCODE -> { + if (!hasExpression) { + throw illegal("error.api_connector_variable_expression_required", name); + } + } + case TIMESTAMP, RANDOM_HEX -> { + // Expression is optional: a format pattern / a byte count. + } + } + + var allowedAlgorithms = switch (e.getKind()) { + case HASH -> Set.of(ApiVariableAlgorithm.SHA256, ApiVariableAlgorithm.MD5); + case HMAC -> Set.of(ApiVariableAlgorithm.HMAC_SHA256, ApiVariableAlgorithm.HMAC_SHA512); + default -> Set.of(); + }; + if (allowedAlgorithms.isEmpty()) { + if (e.getAlgorithm() != null) { + throw illegal("error.api_connector_variable_algorithm_forbidden", name); + } + } else if (e.getAlgorithm() == null || !allowedAlgorithms.contains(e.getAlgorithm())) { + throw illegal("error.api_connector_variable_algorithm_invalid", name); + } + + switch (e.getKind()) { + case ENCODE -> { + if (e.getEncoding() == null) { + throw illegal("error.api_connector_variable_encoding_required", name); + } + } + // CONSTANT deliberately ignores encoding rather than re-encoding — that is what keeps it + // distinct from ENCODE. Rejecting an encoding here avoids a silently ineffective setting. + case CONSTANT, UUID, TIMESTAMP, EPOCH_MILLIS -> { + if (e.getEncoding() != null) { + throw illegal("error.api_connector_variable_encoding_forbidden", name); + } + } + case RANDOM_HEX, HASH, HMAC -> { + // Optional; defaults to HEX. + } + } + + var hasSecret = e.getSecretEncrypted() != null; + if (e.getKind() == ApiVariableKind.HMAC) { + if (!hasSecret) { + throw illegal("error.api_connector_variable_secret_required", name); + } + } else if (hasSecret) { + throw illegal("error.api_connector_variable_secret_forbidden", name); + } + + if (e.getKind() == ApiVariableKind.RANDOM_HEX && hasExpression) { + try { + var count = Integer.parseInt(e.getExpression().trim()); + if (count < 1 || count > 256) { + throw illegal("error.api_connector_variable_random_hex_size", name, 1, 256); + } + } catch (NumberFormatException ex) { + throw illegal("error.api_connector_variable_random_hex_size", name, 1, 256); + } + } + } + + /** + * A submitter must never be able to override a value that is a secret. The database + * enforces the same rule with a CHECK constraint, so it survives a manual data fix. + */ + private void validateOverridable(ApiConnectorVariableEntity e) { + if (e.isOverridable() + && (e.getSecretEncrypted() != null || e.getKind() == ApiVariableKind.HMAC)) { + throw illegal("error.api_connector_variable_overridable_secret", e.getName()); + } + } + + private void validateTarget(ApiConnectorVariableEntity candidate, + List others) { + if (!ApiVariableTargets.isValid(candidate.getTarget())) { + throw illegal("error.api_connector_variable_target_invalid", candidate.getName()); + } + var target = ApiVariableTargets.parse(candidate.getTarget()); + if (target == null) { + return; + } + for (var other : others) { + var otherTarget = ApiVariableTargets.parse(other.getTarget()); + if (otherTarget != null && otherTarget.type() == target.type() + && otherTarget.key().equalsIgnoreCase(target.key())) { + throw illegal("error.api_connector_variable_target_duplicate", candidate.getTarget()); + } + } + } + + private void requireNotReferenced(String name, List others) { + for (var other : others) { + if (ApiVariableTemplate.variableReferences(other.getExpression()).contains(name)) { + throw illegal("error.api_connector_variable_referenced", name, other.getName()); + } + } + } + + private String requireName(String name) { + if (name == null || name.isBlank()) { + throw illegal("error.api_connector_variable_name_required"); + } + var trimmed = name.trim(); + if (!ApiVariableTemplate.NAME_PATTERN.matcher(trimmed).matches()) { + throw illegal("error.api_connector_variable_name_invalid", trimmed); + } + return trimmed; + } + + private ApiVariableKind requireKind(ApiVariableKind kind) { + if (kind == null) { + throw illegal("error.api_connector_variable_kind_required"); + } + return kind; + } + + // --- helpers ------------------------------------------------------------------------------ + + private List load(UUID connectorId, UUID organizationId) { + return variableRepository + .findAllByOrganizationIdAndConnectorIdOrderBySortOrderAscCreatedAtAscIdAsc( + organizationId, connectorId); + } + + private ApiConnectorVariableEntity loadInScope(UUID variableId, UUID connectorId, + UUID organizationId) { + return variableRepository + .findByIdAndOrganizationIdAndConnectorId(variableId, organizationId, connectorId) + .orElseThrow(() -> new ApiConnectorVariableNotFoundException(variableId)); + } + + private void requireConnectorInOrganization(UUID connectorId, UUID organizationId) { + connectorRepository.findByIdAndOrganizationId(connectorId, organizationId) + .orElseThrow(() -> new ApiConnectorNotFoundException(connectorId)); + } + + private static int nextSortOrder(List existing) { + return existing.stream().mapToInt(ApiConnectorVariableEntity::getSortOrder).max().orElse(-1) + 1; + } + + private String encryptSecret(String raw) { + return raw == null || raw.isBlank() ? null : encryptionService.encrypt(raw); + } + + private static String blankToNull(String value) { + return value == null || value.isBlank() ? null : value; + } + + private ApiConnectorVariableView toView(ApiConnectorVariableEntity e) { + return new ApiConnectorVariableView(e.getId(), e.getConnectorId(), e.getName(), e.getKind(), + e.getExpression(), e.getAlgorithm(), e.getEncoding(), e.getSecretEncrypted() != null, + e.getTarget(), e.isOverridable(), e.getDescription(), e.getSortOrder(), + e.getCreatedAt(), e.getUpdatedAt()); + } + + private IllegalApiConnectorVariableException illegal(String key, Object... args) { + return new IllegalApiConnectorVariableException( + messageSource.getMessage(key, args, LocaleContextHolder.getLocale())); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableResolutionService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableResolutionService.java new file mode 100644 index 00000000..57ab9446 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableResolutionService.java @@ -0,0 +1,213 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableLookupService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableResolutionService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableSummaryView; +import com.bablsoft.accessflow.apigov.api.ApiExecutionException; +import com.bablsoft.accessflow.apigov.api.ApiVariableRequestContext; +import com.bablsoft.accessflow.apigov.api.ResolvedApiVariables; +import com.bablsoft.accessflow.apigov.internal.config.ApigovRequestProperties; +import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiConnectorVariableEntity; +import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorVariableRepository; +import com.bablsoft.accessflow.core.api.CredentialEncryptionService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Evaluates a connector's dynamic variables for one outbound call (AF-613). + * + *

Runs after auth headers are resolved, so an expression can sign the finished + * {@code Authorization} value. Variables are evaluated in topological order; each expression is + * rendered against the pre-substitution request context plus the variables already resolved in this + * pass, and the result is fed to {@link ApiVariableEvaluator}. + * + *

Resolved values never leave this call: they are not persisted onto the request row, not written + * into the response snapshot, and not logged. The resolver cannot distinguish a signature (harmless) + * from a {@code CONSTANT} holding a shared secret (not harmless), so every resolved value is treated + * as sensitive — see {@link ResolvedApiVariables#secretValues()}. + */ +@Service +@RequiredArgsConstructor +class DefaultApiConnectorVariableResolutionService + implements ApiConnectorVariableResolutionService, ApiConnectorVariableLookupService { + + private final ApiConnectorVariableRepository variableRepository; + private final CredentialEncryptionService encryptionService; + private final ApiVariableEvaluator evaluator; + private final ApigovRequestProperties requestProperties; + private final MessageSource messageSource; + + @Override + @Transactional(readOnly = true) + public ResolvedApiVariables resolve(UUID organizationId, UUID connectorId, + ApiVariableRequestContext context, + Map overrides) { + var entities = variableRepository + .findAllByOrganizationIdAndConnectorIdOrderBySortOrderAscCreatedAtAscIdAsc( + organizationId, connectorId); + if (entities.isEmpty()) { + return ResolvedApiVariables.empty(); + } + + var byName = entities.stream() + .collect(Collectors.toMap(ApiConnectorVariableEntity::getName, e -> e, (a, b) -> a, + LinkedHashMap::new)); + var nodes = entities.stream() + .map(e -> new ApiVariableGraph.Node(e.getName(), e.getExpression())) + .toList(); + + // Defensive: the admin service already rejected cycles and unknown references at save time. + // Reaching either here means the rows were mutated outside it. + List ordered; + try { + ordered = ApiVariableGraph.evaluationOrder(nodes); + } catch (ApiVariableGraph.CycleException ex) { + throw new ApiExecutionException(msg("error.api_connector_variable_cycle", + String.join(", ", ex.names()))); + } catch (ApiVariableGraph.UnknownReferenceException ex) { + throw new ApiExecutionException(msg("error.api_connector_variable_unknown_reference", + ex.from(), ex.missing())); + } + + var effectiveOverrides = overrides == null ? Map.of() : overrides; + var resolved = new LinkedHashMap(); + for (var node : ordered) { + var entity = byName.get(node.name()); + resolved.put(entity.getName(), valueFor(entity, context, resolved, effectiveOverrides)); + } + + return new ResolvedApiVariables(resolved, injections(entities, resolved)); + } + + private String valueFor(ApiConnectorVariableEntity entity, ApiVariableRequestContext context, + Map resolved, Map overrides) { + var name = entity.getName(); + + // An override replaces the value outright — the kind, algorithm and encoding are not + // re-applied. It is inserted as an opaque literal and never rendered as a template, which is + // what stops an override of "{{someSecret}}" from expanding into that secret's value. + // Variables that depend on this one still recompute over the overridden value. + if (entity.isOverridable() && overrides.containsKey(name)) { + return validate(name, overrides.get(name)); + } + + String input; + try { + input = ApiVariableTemplate.render(entity.getExpression(), + ref -> switch (ref.scope()) { + case VARIABLE_BARE, VARIABLE_QUALIFIED -> resolved.get(ref.key()); + case REQUEST -> requestValue(context, ref.key()); + case FOREIGN -> null; + }, + ApiVariableTemplate.EXPRESSION_STRICT_SCOPES); + } catch (ApiVariableTemplate.UnresolvedReferenceException ex) { + throw new ApiExecutionException(msg("error.api_connector_variable_unknown_reference", + name, ex.reference().raw())); + } + + try { + var secret = entity.getSecretEncrypted() == null + ? null : encryptionService.decrypt(entity.getSecretEncrypted()); + return validate(name, evaluator.evaluate(name, entity.getKind(), entity.getAlgorithm(), + entity.getEncoding(), input, secret)); + } catch (ApiVariableEvaluationException ex) { + throw new ApiExecutionException(msg(ex.messageKey(), ex.args())); + } + } + + /** {@code {{request.headers.}}} is matched case-insensitively, as HTTP headers are. */ + private static String requestValue(ApiVariableRequestContext context, String key) { + if (key.startsWith("headers.")) { + var wanted = key.substring("headers.".length()); + var caseInsensitive = new TreeMap(String.CASE_INSENSITIVE_ORDER); + caseInsensitive.putAll(context.headers()); + return caseInsensitive.get(wanted); + } + return switch (key) { + case "method" -> context.method(); + case "path" -> context.path(); + case "query" -> context.query(); + case "body" -> context.body(); + default -> null; + }; + } + + /** + * No resolved value may carry CR, LF or NUL. Any of them landing in a header is request + * splitting, and a submitter-supplied override is the natural delivery mechanism. The check is + * unconditional rather than header-only because a value reaches a header just as easily through + * a {@code {{name}}} placeholder as through a {@code header:} target. The JDK's own check throws + * {@code IllegalArgumentException} deep inside the client, which would surface as a 500; + * rejecting here yields a clean, localized failure instead. + */ + private String validate(String name, String value) { + var safe = value == null ? "" : value; + if (safe.getBytes(StandardCharsets.UTF_8).length > requestProperties.maxVariableValueBytes()) { + throw new ApiExecutionException(msg("error.api_connector_variable_value_too_large", + name, requestProperties.maxVariableValueBytes())); + } + if (containsControlCharacters(safe)) { + throw new ApiExecutionException(msg("error.api_connector_variable_value_invalid", name)); + } + return safe; + } + + static boolean containsControlCharacters(String value) { + return value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0 || value.indexOf('\0') >= 0; + } + + private static List injections( + List entities, Map resolved) { + var injections = new ArrayList(); + for (var entity : entities) { + var target = ApiVariableTargets.parse(entity.getTarget()); + if (target != null) { + injections.add(new ResolvedApiVariables.ApiVariableInjection( + target.type(), target.key(), resolved.getOrDefault(entity.getName(), ""))); + } + } + return injections; + } + + @Override + @Transactional(readOnly = true) + public List summariesForConnector(UUID connectorId, + UUID organizationId) { + return variableRepository + .findAllByOrganizationIdAndConnectorIdOrderBySortOrderAscCreatedAtAscIdAsc( + organizationId, connectorId) + .stream() + .map(e -> new ApiConnectorVariableSummaryView(e.getName(), e.getKind(), + e.getDescription(), e.isOverridable())) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public Set overridableNames(UUID connectorId, UUID organizationId) { + return variableRepository + .findAllByOrganizationIdAndConnectorIdOrderBySortOrderAscCreatedAtAscIdAsc( + organizationId, connectorId) + .stream() + .filter(ApiConnectorVariableEntity::isOverridable) + .map(ApiConnectorVariableEntity::getName) + .collect(Collectors.toSet()); + } + + private String msg(String key, Object... args) { + return messageSource.getMessage(key, args, LocaleContextHolder.getLocale()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiRequestService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiRequestService.java index 58ec8d24..04899213 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiRequestService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiRequestService.java @@ -3,6 +3,7 @@ import com.bablsoft.accessflow.core.api.Permission; import com.bablsoft.accessflow.apigov.api.ApiBodyType; import com.bablsoft.accessflow.apigov.api.ApiConnectorNotFoundException; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableLookupService; import com.bablsoft.accessflow.apigov.api.ApiFormField; import com.bablsoft.accessflow.apigov.api.ApiOperation; import com.bablsoft.accessflow.apigov.api.ApiProtocol; @@ -44,6 +45,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; @@ -59,6 +61,11 @@ public class DefaultApiRequestService implements ApiRequestService { private static final Set MUTATING_VERBS = Set.of("POST", "PUT", "PATCH", "DELETE"); + // A submitter overriding dozens of variables is not a real use case; the cap keeps the + // persisted jsonb bounded and the reviewer's view readable. + private static final int MAX_VARIABLE_OVERRIDES = 32; + private static final TypeReference> STRING_MAP_TYPE = new TypeReference<>() { + }; private final ApiRequestRepository requestRepository; private final ApiConnectorRepository connectorRepository; @@ -69,6 +76,7 @@ public class DefaultApiRequestService implements ApiRequestService { private final ApiExecutionService executionService; private final AiAnalysisLookupService aiAnalysisLookupService; private final UserQueryService userQueryService; + private final ApiConnectorVariableLookupService variableLookupService; private final ApigovRequestProperties requestProperties; private final AuditLogService auditLogService; private final ApplicationEventPublisher eventPublisher; @@ -88,6 +96,7 @@ public ApiRequestSubmissionResult submit(SubmitApiRequestCommand command) { validateAgainstSchema(connector, command); var bodyType = command.bodyType() == null ? ApiBodyType.RAW : command.bodyType(); enforceBodySize(bodyType, command); + var variableOverrides = validateVariableOverrides(connector, command, permission); var entity = new ApiRequestEntity(); entity.setId(UUID.randomUUID()); @@ -104,6 +113,7 @@ public ApiRequestSubmissionResult submit(SubmitApiRequestCommand command) { entity.setRequestBody(command.requestBody()); entity.setFormFields(writeFormFields(command.formFields())); entity.setBinaryFilename(command.binaryFilename()); + entity.setVariableOverrides(writeJson(variableOverrides)); entity.setTraceId(TraceContext.newTraceId()); entity.setSpanId(TraceContext.newSpanId()); entity.setWrite(write); @@ -119,8 +129,11 @@ public ApiRequestSubmissionResult submit(SubmitApiRequestCommand command) { if (breakGlass) { return breakGlassExecute(connector, entity, command, permission); } + // The override *count* only — audit rows are long-retention, and an override value is + // submitter-authored input that may be sensitive in its own right. audit(AuditAction.API_REQUEST_SUBMITTED, entity, command.submittedIp(), command.submittedUserAgent(), - Map.of("verb", command.verb(), "write", write)); + Map.of("verb", command.verb(), "write", write, + "variable_override_count", variableOverrides.size())); eventPublisher.publishEvent(new ApiRequestSubmittedEvent(entity.getId())); return new ApiRequestSubmissionResult(entity.getId(), entity.getStatus()); } @@ -318,7 +331,8 @@ private ApiRequestView buildView(ApiRequestEntity e, boolean detail) { summary != null ? summary.riskLevel() : null, summary != null ? summary.riskScore() : null, summary != null ? summary.summary() : null, - e.getBodyType(), e.getScheduledFor(), e.getTraceId(), e.getSpanId(), + e.getBodyType(), readMap(e.getVariableOverrides()), e.getScheduledFor(), + e.getTraceId(), e.getSpanId(), e.getResponseStatusCode(), e.getResponseDurationMs(), e.getResponseBytes(), e.isResponseTruncated(), snapshotPreview, previewTruncated, e.getResponseContentType(), e.getErrorMessage(), e.getCreatedAt(), decisions); @@ -377,6 +391,55 @@ private void enforceBodySize(ApiBodyType bodyType, SubmitApiRequestCommand comma } } + /** + * Validates the submitter's per-request connector-variable overrides (AF-613) and returns the + * map to persist. + * + *

Enforced here rather than in the controller so break-glass — and any future submit path — + * inherits it. Three rules matter: + *

    + *
  • Supplying overrides at all requires {@code can_override_variables} on the connector, a + * capability distinct from being able to submit.
  • + *
  • A name outside the connector's overridable set is rejected with a single message + * shape, whether the variable is unknown, not overridable, or secret-bearing. Distinct + * messages would let a submitter enumerate which variables hold secrets.
  • + *
  • Values are bounded and must not carry CR / LF / NUL. Rejecting here gives an immediate + * 422 rather than a failed execution hours after a reviewer approved the request.
  • + *
+ */ + private Map validateVariableOverrides( + ApiConnectorEntity connector, SubmitApiRequestCommand command, + ResolvedApiConnectorPermission permission) { + var overrides = command.variableOverrides(); + if (overrides == null || overrides.isEmpty()) { + return Map.of(); + } + if (permission == null || !permission.canOverrideVariables()) { + throw new ApiRequestPermissionException( + "Variable overrides are not permitted for this connector"); + } + if (overrides.size() > MAX_VARIABLE_OVERRIDES) { + throw new ApiRequestValidationException( + "At most " + MAX_VARIABLE_OVERRIDES + " variable overrides may be supplied"); + } + var allowed = variableLookupService.overridableNames(connector.getId(), + command.organizationId()); + for (var entry : overrides.entrySet()) { + if (!allowed.contains(entry.getKey())) { + throw new ApiRequestValidationException( + "Connector variable override not permitted: " + entry.getKey()); + } + var value = entry.getValue() == null ? "" : entry.getValue(); + if (value.getBytes(StandardCharsets.UTF_8).length + > requestProperties.maxVariableValueBytes() + || DefaultApiConnectorVariableResolutionService.containsControlCharacters(value)) { + throw new ApiRequestValidationException( + "Invalid override value for variable " + entry.getKey()); + } + } + return Map.copyOf(overrides); + } + private static long formFieldsSize(List fields) { if (fields == null) { return 0L; @@ -414,6 +477,17 @@ private String writeJson(Map map) { return objectMapper.writeValueAsString(map == null ? Map.of() : map); } + private Map readMap(String json) { + if (json == null || json.isBlank()) { + return Map.of(); + } + try { + return objectMapper.readValue(json, STRING_MAP_TYPE); + } catch (RuntimeException ex) { + return Map.of(); + } + } + private static Pageable toPageable(PageRequest pageRequest) { return org.springframework.data.domain.PageRequest.of(pageRequest.page(), pageRequest.size()); } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiReviewService.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiReviewService.java index 95b94c4e..053a458a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiReviewService.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/DefaultApiReviewService.java @@ -17,6 +17,7 @@ import com.bablsoft.accessflow.core.api.PageResponse; import com.bablsoft.accessflow.core.api.QueryStatus; import lombok.RequiredArgsConstructor; +import tools.jackson.databind.ObjectMapper; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +36,7 @@ public class DefaultApiReviewService implements ApiReviewService { private final ApiRequestStateService stateService; private final AiAnalysisLookupService aiAnalysisLookupService; private final ApplicationEventPublisher eventPublisher; + private final ObjectMapper objectMapper; @Override @Transactional(readOnly = true) @@ -116,6 +118,19 @@ private ApiRequestEntity require(UUID id, UUID organizationId) { .orElseThrow(() -> new ApiRequestNotFoundException(id)); } + /** Counts the keys in the persisted overrides jsonb without materializing a map per row. */ + private int variableOverrideCount(String json) { + if (json == null || json.isBlank() || "{}".equals(json.trim())) { + return 0; + } + try { + var node = objectMapper.readTree(json); + return node.isObject() ? node.size() : 0; + } catch (RuntimeException ex) { + return 0; + } + } + private PendingApiReview toPending(ApiRequestEntity e) { var connectorName = connectorRepository.findById(e.getConnectorId()) .map(ApiConnectorEntity::getName).orElse(null); @@ -125,6 +140,7 @@ private PendingApiReview toPending(ApiRequestEntity e) { e.getVerb(), e.getRequestPath(), e.isWrite(), e.getJustification(), e.getAiAnalysisId(), summary != null ? summary.riskLevel() : null, summary != null ? summary.riskScore() : null, - summary != null ? summary.summary() : null, STAGE, e.getCreatedAt()); + summary != null ? summary.summary() : null, STAGE, + variableOverrideCount(e.getVariableOverrides()), e.getCreatedAt()); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/EffectiveApiConnectorPermissionResolver.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/EffectiveApiConnectorPermissionResolver.java index 5f25369a..e32d943e 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/EffectiveApiConnectorPermissionResolver.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/EffectiveApiConnectorPermissionResolver.java @@ -80,12 +80,14 @@ private static ResolvedApiConnectorPermission merge(UUID connectorId, UUID userI boolean canRead = false; boolean canWrite = false; boolean canBreakGlass = false; + boolean canOverrideVariables = false; Instant expiresAt = parts.get(0).expiresAt(); boolean anyNeverExpires = false; for (var p : parts) { canRead |= p.canRead(); canWrite |= p.canWrite(); canBreakGlass |= p.canBreakGlass(); + canOverrideVariables |= p.canOverrideVariables(); if (p.expiresAt() == null) { anyNeverExpires = true; } else if (expiresAt != null && p.expiresAt().isAfter(expiresAt)) { @@ -98,6 +100,7 @@ private static ResolvedApiConnectorPermission merge(UUID connectorId, UUID userI canRead, canWrite, canBreakGlass, + canOverrideVariables, unionAllowList(parts), intersectRestriction(parts), anyNeverExpires ? null : expiresAt); @@ -146,22 +149,26 @@ private static List intersectRestriction(List parts) { */ record ResolvedApiConnectorPermission(UUID connectorId, UUID userId, boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, List allowedOperations, List restrictedResponseFields, Instant expiresAt) { } private record Contribution(boolean canRead, boolean canWrite, boolean canBreakGlass, - String[] allowedOperations, String[] restrictedResponseFields, + boolean canOverrideVariables, String[] allowedOperations, + String[] restrictedResponseFields, Instant expiresAt) { static Contribution from(ApiConnectorUserPermissionEntity e) { return new Contribution(e.isCanRead(), e.isCanWrite(), e.isCanBreakGlass(), - e.getAllowedOperations(), e.getRestrictedResponseFields(), e.getExpiresAt()); + e.isCanOverrideVariables(), e.getAllowedOperations(), + e.getRestrictedResponseFields(), e.getExpiresAt()); } static Contribution from(ApiConnectorGroupPermissionEntity e) { return new Contribution(e.isCanRead(), e.isCanWrite(), e.isCanBreakGlass(), - e.getAllowedOperations(), e.getRestrictedResponseFields(), e.getExpiresAt()); + e.isCanOverrideVariables(), e.getAllowedOperations(), + e.getRestrictedResponseFields(), e.getExpiresAt()); } } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/config/ApigovRequestProperties.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/config/ApigovRequestProperties.java index 25b2d092..46260d48 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/config/ApigovRequestProperties.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/config/ApigovRequestProperties.java @@ -11,10 +11,12 @@ * (and therefore downloadable) API response body — the absolute backstop above any per-connector * {@code max_response_bytes}. {@code responsePreviewBytes} bounds how much of the stored snapshot is * embedded inline in the detail view; the full body is served by the download endpoint. + * {@code maxVariableValueBytes} caps a single resolved dynamic-variable value (AF-613) — a digest or + * nonce is tens of bytes, so a large value means a runaway expression or an abusive override. */ @ConfigurationProperties("accessflow.apigov") public record ApigovRequestProperties(long maxRequestBodyBytes, long maxResponseBytes, - long responsePreviewBytes) { + long responsePreviewBytes, int maxVariableValueBytes) { public ApigovRequestProperties { if (maxRequestBodyBytes <= 0) { @@ -26,5 +28,8 @@ public record ApigovRequestProperties(long maxRequestBodyBytes, long maxResponse if (responsePreviewBytes <= 0) { responsePreviewBytes = 65_536L; } + if (maxVariableValueBytes <= 0) { + maxVariableValueBytes = 8192; + } } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorGroupPermissionEntity.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorGroupPermissionEntity.java index 2bf3e512..ae1ed806 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorGroupPermissionEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorGroupPermissionEntity.java @@ -42,6 +42,11 @@ public class ApiConnectorGroupPermissionEntity { @Column(name = "can_break_glass", nullable = false) private boolean canBreakGlass = false; + // AF-613: may this grantee supply per-request overrides for the connector's overridable dynamic + // variables? A distinct capability from submitting, gated exactly like can_break_glass. + @Column(name = "can_override_variables", nullable = false) + private boolean canOverrideVariables = false; + @Column(name = "expires_at") private Instant expiresAt; diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorUserPermissionEntity.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorUserPermissionEntity.java index 568f555e..0d86e9e2 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorUserPermissionEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorUserPermissionEntity.java @@ -38,6 +38,11 @@ public class ApiConnectorUserPermissionEntity { @Column(name = "can_break_glass", nullable = false) private boolean canBreakGlass = false; + // AF-613: may this grantee supply per-request overrides for the connector's overridable dynamic + // variables? A distinct capability from submitting, gated exactly like can_break_glass. + @Column(name = "can_override_variables", nullable = false) + private boolean canOverrideVariables = false; + @Column(name = "expires_at") private Instant expiresAt; diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorVariableEntity.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorVariableEntity.java new file mode 100644 index 00000000..7156706e --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiConnectorVariableEntity.java @@ -0,0 +1,102 @@ +package com.bablsoft.accessflow.apigov.internal.persistence.entity; + +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.type.PostgreSQLEnumJdbcType; + +import java.time.Instant; +import java.util.UUID; + +/** + * A named per-connector expression evaluated at execution time and substituted into the outbound + * request via {@code {{name}}} placeholders (AF-613). + */ +@Entity +@Table(name = "api_connector_variables") +@Getter +@Setter +@NoArgsConstructor +public class ApiConnectorVariableEntity { + + @Id + private UUID id; + + @Column(name = "organization_id", nullable = false) + private UUID organizationId; + + @Column(name = "connector_id", nullable = false) + private UUID connectorId; + + @Column(nullable = false, columnDefinition = "text") + private String name; + + @Enumerated(EnumType.STRING) + @JdbcType(PostgreSQLEnumJdbcType.class) + @Column(nullable = false, columnDefinition = "api_variable_kind") + private ApiVariableKind kind; + + // The input template for the value; itself placeholder-expanded. Required or forbidden depending + // on the kind (validated in the admin service, not the DB). + @Column(columnDefinition = "text") + private String expression; + + @Enumerated(EnumType.STRING) + @JdbcType(PostgreSQLEnumJdbcType.class) + @Column(columnDefinition = "api_variable_algorithm") + private ApiVariableAlgorithm algorithm; + + @Enumerated(EnumType.STRING) + @JdbcType(PostgreSQLEnumJdbcType.class) + @Column(columnDefinition = "api_variable_encoding") + private ApiVariableEncoding encoding; + + // AES-256-GCM shared secret for kind=HMAC. Same rules as auth_credentials_encrypted: never + // serialized, never returned in a GET, never logged. + @JsonIgnore + @Column(name = "secret_encrypted", columnDefinition = "text") + private String secretEncrypted; + + // Optional auto-injection site — 'header:' or 'query:' — applied after substitution. + @Column(columnDefinition = "text") + private String target; + + @Column(nullable = false) + private boolean overridable = false; + + @Column(columnDefinition = "text") + private String description; + + // Explicit rather than ordering by created_at: evaluation order is observable, and + // CURRENT_TIMESTAMP is transaction-constant so same-transaction inserts would tie. + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Version + @Column(nullable = false) + private long version; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt = Instant.now(); + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt = Instant.now(); + + @PreUpdate + void onUpdate() { + this.updatedAt = Instant.now(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiRequestEntity.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiRequestEntity.java index 67dce71c..87a9543d 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiRequestEntity.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/entity/ApiRequestEntity.java @@ -78,6 +78,13 @@ public class ApiRequestEntity { @Column(name = "binary_filename", columnDefinition = "text") private String binaryFilename; + // AF-613: submitter-supplied values for connector variables marked overridable. Persisted rather + // than transient because a reviewer approves what is stored, and submit -> review -> execute is + // asynchronous — an unstored override would let the effective request change after approval. + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "variable_overrides", nullable = false, columnDefinition = "jsonb") + private String variableOverrides = "{}"; + @Column(name = "is_write", nullable = false) private boolean write = false; diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/repo/ApiConnectorVariableRepository.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/repo/ApiConnectorVariableRepository.java new file mode 100644 index 00000000..5d182459 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/persistence/repo/ApiConnectorVariableRepository.java @@ -0,0 +1,25 @@ +package com.bablsoft.accessflow.apigov.internal.persistence.repo; + +import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiConnectorVariableEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface ApiConnectorVariableRepository + extends JpaRepository { + + /** + * Evaluation order: {@code (sort_order, created_at, id)} is a total order, so the topological + * sort's tie-break between independent variables is deterministic across nodes and restarts. + */ + List + findAllByOrganizationIdAndConnectorIdOrderBySortOrderAscCreatedAtAscIdAsc( + UUID organizationId, UUID connectorId); + + Optional findByIdAndOrganizationIdAndConnectorId( + UUID id, UUID organizationId, UUID connectorId); + + long countByOrganizationIdAndConnectorId(UUID organizationId, UUID connectorId); +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorController.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorController.java index 6f5a8dfa..6e4e360d 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorController.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorController.java @@ -2,6 +2,7 @@ import com.bablsoft.accessflow.core.api.Permission; import com.bablsoft.accessflow.apigov.api.ApiConnectorAdminService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableLookupService; import com.bablsoft.accessflow.audit.api.AuditAction; import com.bablsoft.accessflow.audit.api.AuditResourceType; import com.bablsoft.accessflow.audit.api.RequestAuditContext; @@ -36,6 +37,7 @@ class ApiConnectorController { private final ApiConnectorAdminService service; + private final ApiConnectorVariableLookupService variableLookupService; private final ApiGovAuditWriter auditWriter; @GetMapping @@ -62,6 +64,31 @@ ApiConnectorResponse get(@PathVariable UUID id, Authentication authentication) { return ApiConnectorResponse.from(view); } + @GetMapping("/{id}/variables/summary") + @Operation(summary = "List a connector's dynamic variables, projected to the submitter-safe fields", + description = "Lets the request composer show which {{name}} placeholders are available and " + + "which may be overridden per request. Carries no expression, algorithm or " + + "secret — a submitter may reference a signature variable but must never learn " + + "how it is computed. Admin CRUD lives under /variables (AF-613).") + @ApiResponse(responseCode = "200", description = "Variable summaries, in evaluation order") + @ApiResponse(responseCode = "404", description = "Connector not found or not accessible") + ApiConnectorVariableSummaryListResponse variableSummaries(@PathVariable UUID id, + Authentication authentication) { + var caller = claims(authentication); + // Reuses the same connector visibility check as GET /{id} — a caller who cannot see the + // connector must not learn its variable names either. + if (isAdmin(caller)) { + service.getForAdmin(id, caller.organizationId()); + } else { + service.getForUser(id, caller.organizationId(), caller.userId()); + } + var summaries = variableLookupService.summariesForConnector(id, caller.organizationId()) + .stream() + .map(ApiConnectorVariableSummaryResponse::from) + .toList(); + return new ApiConnectorVariableSummaryListResponse(summaries); + } + @PostMapping @ResponseStatus(HttpStatus.CREATED) @PreAuthorize("hasAuthority('PERM_API_CONNECTOR_MANAGE')") diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorGroupPermissionResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorGroupPermissionResponse.java index 24cb79b4..0d5d63af 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorGroupPermissionResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorGroupPermissionResponse.java @@ -15,6 +15,7 @@ public record ApiConnectorGroupPermissionResponse( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields, @@ -22,7 +23,7 @@ public record ApiConnectorGroupPermissionResponse( static ApiConnectorGroupPermissionResponse from(ApiConnectorGroupPermissionView v) { return new ApiConnectorGroupPermissionResponse(v.id(), v.connectorId(), v.groupId(), - v.groupName(), v.memberCount(), v.canRead(), v.canWrite(), v.canBreakGlass(), + v.groupName(), v.memberCount(), v.canRead(), v.canWrite(), v.canBreakGlass(), v.canOverrideVariables(), v.expiresAt(), v.allowedOperations(), v.restrictedResponseFields(), v.createdAt()); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorPermissionResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorPermissionResponse.java index f487abb2..41ee7ca5 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorPermissionResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorPermissionResponse.java @@ -14,6 +14,7 @@ public record ApiConnectorPermissionResponse( boolean canRead, boolean canWrite, boolean canBreakGlass, + boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields, @@ -21,7 +22,7 @@ public record ApiConnectorPermissionResponse( static ApiConnectorPermissionResponse from(ApiConnectorPermissionView v) { return new ApiConnectorPermissionResponse(v.id(), v.userId(), v.userEmail(), v.userDisplayName(), - v.canRead(), v.canWrite(), v.canBreakGlass(), v.expiresAt(), v.allowedOperations(), + v.canRead(), v.canWrite(), v.canBreakGlass(), v.canOverrideVariables(), v.expiresAt(), v.allowedOperations(), v.restrictedResponseFields(), v.createdAt()); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableController.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableController.java new file mode 100644 index 00000000..2fe6b19b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableController.java @@ -0,0 +1,145 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableAdminService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableView; +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditResourceType; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.security.api.JwtClaims; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/api-connectors/{connectorId}/variables") +@Tag(name = "API connector variables", + description = "Per-connector dynamic variables — request signing, nonces, timestamps (AF-613)") +@PreAuthorize("hasAuthority('PERM_API_CONNECTOR_MANAGE')") +@RequiredArgsConstructor +class ApiConnectorVariableController { + + private final ApiConnectorVariableAdminService service; + private final ApiGovAuditWriter auditWriter; + + @GetMapping + @Operation(summary = "List the dynamic variables configured on an API connector") + @ApiResponse(responseCode = "200", description = "List of variables, in evaluation order") + @ApiResponse(responseCode = "404", description = "Connector not found") + ApiConnectorVariableListResponse list(@PathVariable UUID connectorId, + Authentication authentication) { + var caller = claims(authentication); + var variables = service.listForConnector(connectorId, caller.organizationId()).stream() + .map(ApiConnectorVariableResponse::from) + .toList(); + return new ApiConnectorVariableListResponse(variables); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation(summary = "Create a dynamic variable on an API connector") + @ApiResponse(responseCode = "201", description = "Variable created") + @ApiResponse(responseCode = "404", description = "Connector not found") + @ApiResponse(responseCode = "422", + description = "Invalid name, kind/field combination, target, or a dependency cycle") + ApiConnectorVariableResponse create(@PathVariable UUID connectorId, + @Valid @RequestBody CreateApiConnectorVariableRequest body, + Authentication authentication, + RequestAuditContext auditContext) { + var caller = claims(authentication); + var view = service.create(connectorId, caller.organizationId(), body.toCommand()); + auditWriter.record(AuditAction.API_CONNECTOR_VARIABLE_CREATED, + AuditResourceType.API_CONNECTOR, connectorId, caller, metadata(view), auditContext); + return ApiConnectorVariableResponse.from(view); + } + + @PutMapping("/{variableId}") + @Operation(summary = "Update a dynamic variable") + @ApiResponse(responseCode = "200", description = "Variable updated") + @ApiResponse(responseCode = "404", description = "Connector or variable not found") + @ApiResponse(responseCode = "422", + description = "Invalid name, kind/field combination, target, or a dependency cycle") + ApiConnectorVariableResponse update(@PathVariable UUID connectorId, + @PathVariable UUID variableId, + @Valid @RequestBody UpdateApiConnectorVariableRequest body, + Authentication authentication, + RequestAuditContext auditContext) { + var caller = claims(authentication); + var view = service.update(variableId, connectorId, caller.organizationId(), body.toCommand()); + auditWriter.record(AuditAction.API_CONNECTOR_VARIABLE_UPDATED, + AuditResourceType.API_CONNECTOR, connectorId, caller, metadata(view), auditContext); + return ApiConnectorVariableResponse.from(view); + } + + @DeleteMapping("/{variableId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation(summary = "Delete a dynamic variable") + @ApiResponse(responseCode = "204", description = "Variable deleted") + @ApiResponse(responseCode = "404", description = "Connector or variable not found") + @ApiResponse(responseCode = "422", description = "Another variable still references this one") + void delete(@PathVariable UUID connectorId, @PathVariable UUID variableId, + Authentication authentication, RequestAuditContext auditContext) { + var caller = claims(authentication); + service.delete(variableId, connectorId, caller.organizationId()); + var metadata = new HashMap(); + metadata.put("variable_id", variableId.toString()); + auditWriter.record(AuditAction.API_CONNECTOR_VARIABLE_DELETED, + AuditResourceType.API_CONNECTOR, connectorId, caller, metadata, auditContext); + } + + @PutMapping("/order") + @Operation(summary = "Reassign the evaluation order of a connector's dynamic variables", + description = "The body carries the connector's complete variable id list in the desired " + + "order. Evaluation order is observable for time- and randomness-dependent kinds.") + @ApiResponse(responseCode = "200", description = "Variables reordered") + @ApiResponse(responseCode = "404", description = "Connector not found") + @ApiResponse(responseCode = "422", description = "The id list is not the connector's complete set") + ApiConnectorVariableListResponse reorder( + @PathVariable UUID connectorId, + @Valid @RequestBody ReorderApiConnectorVariablesRequest body, + Authentication authentication, RequestAuditContext auditContext) { + var caller = claims(authentication); + var views = service.reorder(connectorId, caller.organizationId(), body.toCommand()); + var metadata = new HashMap(); + metadata.put("variable_count", views.size()); + auditWriter.record(AuditAction.API_CONNECTOR_VARIABLES_REORDERED, + AuditResourceType.API_CONNECTOR, connectorId, caller, metadata, auditContext); + return new ApiConnectorVariableListResponse( + views.stream().map(ApiConnectorVariableResponse::from).toList()); + } + + /** Audit metadata never carries the expression or the secret — only the shape of the change. */ + private static Map metadata(ApiConnectorVariableView view) { + var map = new HashMap(); + map.put("variable_id", view.id().toString()); + map.put("name", view.name()); + map.put("kind", view.kind().name()); + map.put("has_secret", view.hasSecret()); + map.put("overridable", view.overridable()); + if (view.target() != null) { + map.put("target", view.target()); + } + return map; + } + + private static JwtClaims claims(Authentication authentication) { + return (JwtClaims) authentication.getPrincipal(); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableListResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableListResponse.java new file mode 100644 index 00000000..d85c34c4 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableListResponse.java @@ -0,0 +1,6 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import java.util.List; + +public record ApiConnectorVariableListResponse(List content) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableResponse.java new file mode 100644 index 00000000..a831315b --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableResponse.java @@ -0,0 +1,36 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableView; +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; + +import java.time.Instant; +import java.util.UUID; + +/** + * Admin view of a connector variable (AF-613). Carries no secret — only {@code hasSecret} — matching + * the rule that a connector's stored credentials are never returned by a GET. + */ +public record ApiConnectorVariableResponse( + UUID id, + UUID connectorId, + String name, + ApiVariableKind kind, + String expression, + ApiVariableAlgorithm algorithm, + ApiVariableEncoding encoding, + boolean hasSecret, + String target, + boolean overridable, + String description, + int sortOrder, + Instant createdAt, + Instant updatedAt) { + + public static ApiConnectorVariableResponse from(ApiConnectorVariableView v) { + return new ApiConnectorVariableResponse(v.id(), v.connectorId(), v.name(), v.kind(), + v.expression(), v.algorithm(), v.encoding(), v.hasSecret(), v.target(), + v.overridable(), v.description(), v.sortOrder(), v.createdAt(), v.updatedAt()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableSummaryListResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableSummaryListResponse.java new file mode 100644 index 00000000..7b363395 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableSummaryListResponse.java @@ -0,0 +1,7 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import java.util.List; + +public record ApiConnectorVariableSummaryListResponse( + List content) { +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableSummaryResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableSummaryResponse.java new file mode 100644 index 00000000..f8da28c5 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableSummaryResponse.java @@ -0,0 +1,21 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableSummaryView; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; + +/** + * The submitter-visible projection of a connector variable (AF-613) — what the request composer + * needs to reference {@code {{name}}} and offer an override. Deliberately omits the expression, + * algorithm, encoding and any hint of a stored secret. + */ +public record ApiConnectorVariableSummaryResponse( + String name, + ApiVariableKind kind, + String description, + boolean overridable) { + + public static ApiConnectorVariableSummaryResponse from(ApiConnectorVariableSummaryView v) { + return new ApiConnectorVariableSummaryResponse(v.name(), v.kind(), v.description(), + v.overridable()); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiGovExceptionHandler.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiGovExceptionHandler.java index 238ec33b..c307cded 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiGovExceptionHandler.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiGovExceptionHandler.java @@ -5,6 +5,7 @@ import com.bablsoft.accessflow.apigov.api.ApiConnectorMaskingPolicyNotFoundException; import com.bablsoft.accessflow.apigov.api.ApiConnectorNotFoundException; import com.bablsoft.accessflow.apigov.api.ApiConnectorPermissionNotFoundException; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableNotFoundException; import com.bablsoft.accessflow.apigov.api.ApiExecutionException; import com.bablsoft.accessflow.apigov.api.IllegalApiConnectorClassificationTagException; import com.bablsoft.accessflow.apigov.api.IllegalApiConnectorMaskingPolicyException; @@ -15,6 +16,7 @@ import com.bablsoft.accessflow.apigov.api.ApiSchemaNotFoundException; import com.bablsoft.accessflow.apigov.api.ApiSchemaParseException; import com.bablsoft.accessflow.apigov.api.DuplicateApiConnectorNameException; +import com.bablsoft.accessflow.apigov.api.IllegalApiConnectorVariableException; import com.bablsoft.accessflow.apigov.api.IllegalApiRequestStateException; import com.bablsoft.accessflow.apigov.api.SelfApprovalNotAllowedException; import lombok.RequiredArgsConstructor; @@ -153,6 +155,19 @@ ProblemDetail handleIllegalClassificationTag(IllegalApiConnectorClassificationTa return problem(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), "ILLEGAL_API_CLASSIFICATION_TAG"); } + @ExceptionHandler(ApiConnectorVariableNotFoundException.class) + ProblemDetail handleVariableNotFound(ApiConnectorVariableNotFoundException ex) { + return problem(HttpStatus.NOT_FOUND, msg("error.api_connector_variable_not_found"), + "API_CONNECTOR_VARIABLE_NOT_FOUND"); + } + + @ExceptionHandler(IllegalApiConnectorVariableException.class) + ProblemDetail handleIllegalVariable(IllegalApiConnectorVariableException ex) { + // Message resolved at the throw site via MessageSource — see + // DefaultApiConnectorVariableAdminService. It names variables only, never values or secrets. + return problem(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), "ILLEGAL_API_CONNECTOR_VARIABLE"); + } + private static ProblemDetail problem(HttpStatus status, String detail, String error) { var pd = ProblemDetail.forStatusAndDetail(status, detail); pd.setProperty("error", error); diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiRequestResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiRequestResponse.java index 393fddaa..c8950c9a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiRequestResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ApiRequestResponse.java @@ -9,6 +9,7 @@ import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.UUID; public record ApiRequestResponse( @@ -29,6 +30,7 @@ public record ApiRequestResponse( Integer aiRiskScore, String aiSummary, ApiBodyType bodyType, + Map variableOverrides, Instant scheduledFor, String traceId, String spanId, @@ -47,7 +49,8 @@ static ApiRequestResponse from(ApiRequestView v) { return new ApiRequestResponse(v.id(), v.connectorId(), v.connectorName(), v.submittedBy(), v.submittedByEmail(), v.operationId(), v.verb(), v.requestPath(), v.write(), v.status(), v.submissionReason(), v.justification(), v.aiAnalysisId(), v.aiRiskLevel(), v.aiRiskScore(), - v.aiSummary(), v.bodyType(), v.scheduledFor(), v.traceId(), v.spanId(), + v.aiSummary(), v.bodyType(), v.variableOverrides(), v.scheduledFor(), v.traceId(), + v.spanId(), v.responseStatusCode(), v.responseDurationMs(), v.responseBytes(), v.responseTruncated(), v.responseSnapshot(), v.responseSnapshotPreviewTruncated(), v.responseContentType(), v.errorMessage(), v.createdAt(), v.decisions()); diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/CreateApiConnectorVariableRequest.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/CreateApiConnectorVariableRequest.java new file mode 100644 index 00000000..71c02915 --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/CreateApiConnectorVariableRequest.java @@ -0,0 +1,36 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import com.bablsoft.accessflow.apigov.api.CreateApiConnectorVariableCommand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record CreateApiConnectorVariableRequest( + @NotBlank(message = "{validation.api_connector_variable.name.required}") + @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_]{0,63}$", + message = "{validation.api_connector_variable.name.invalid}") + String name, + @NotNull(message = "{validation.api_connector_variable.kind.required}") + ApiVariableKind kind, + @Size(max = 8192, message = "{validation.api_connector_variable.expression.too_long}") + String expression, + ApiVariableAlgorithm algorithm, + ApiVariableEncoding encoding, + @Size(max = 4096, message = "{validation.api_connector_variable.secret.too_long}") + String secret, + @Size(max = 140, message = "{validation.api_connector_variable.target.invalid}") + String target, + Boolean overridable, + @Size(max = 512, message = "{validation.api_connector_variable.description.too_long}") + String description, + Integer sortOrder) { + + public CreateApiConnectorVariableCommand toCommand() { + return new CreateApiConnectorVariableCommand(name, kind, expression, algorithm, encoding, + secret, target, overridable, description, sortOrder); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/GrantApiConnectorGroupPermissionRequest.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/GrantApiConnectorGroupPermissionRequest.java index 0a8caca2..893756bc 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/GrantApiConnectorGroupPermissionRequest.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/GrantApiConnectorGroupPermissionRequest.java @@ -13,12 +13,15 @@ public record GrantApiConnectorGroupPermissionRequest( boolean canRead, boolean canWrite, boolean canBreakGlass, + /** AF-613. Boxed so an existing client that omits it still works; null means false. */ + Boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields) { GrantApiConnectorGroupPermissionCommand toCommand() { return new GrantApiConnectorGroupPermissionCommand(groupId, canRead, canWrite, canBreakGlass, + Boolean.TRUE.equals(canOverrideVariables), expiresAt, allowedOperations, restrictedResponseFields); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/GrantApiConnectorPermissionRequest.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/GrantApiConnectorPermissionRequest.java index 20ff5b01..7672dab2 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/GrantApiConnectorPermissionRequest.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/GrantApiConnectorPermissionRequest.java @@ -13,12 +13,14 @@ public record GrantApiConnectorPermissionRequest( boolean canRead, boolean canWrite, boolean canBreakGlass, + /** AF-613. Boxed so an existing client that omits it still works; null means false. */ + Boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields) { GrantApiConnectorPermissionCommand toCommand() { return new GrantApiConnectorPermissionCommand(userId, canRead, canWrite, canBreakGlass, - expiresAt, allowedOperations, restrictedResponseFields); + Boolean.TRUE.equals(canOverrideVariables), expiresAt, allowedOperations, restrictedResponseFields); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/PendingApiReviewResponse.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/PendingApiReviewResponse.java index ef8d2ed4..f2541a16 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/PendingApiReviewResponse.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/PendingApiReviewResponse.java @@ -11,13 +11,14 @@ public record PendingApiReviewResponse( UUID apiRequestId, UUID connectorId, String connectorName, UUID submittedByUserId, String verb, String requestPath, boolean write, String justification, UUID aiAnalysisId, RiskLevel aiRiskLevel, - Integer aiRiskScore, String aiSummary, int currentStage, Instant createdAt) { + Integer aiRiskScore, String aiSummary, int currentStage, int variableOverrideCount, + Instant createdAt) { static PendingApiReviewResponse from(ApiReviewService.PendingApiReview p) { return new PendingApiReviewResponse(p.apiRequestId(), p.connectorId(), p.connectorName(), p.submittedByUserId(), p.verb(), p.requestPath(), p.write(), p.justification(), p.aiAnalysisId(), p.aiRiskLevel(), p.aiRiskScore(), p.aiSummary(), p.currentStage(), - p.createdAt()); + p.variableOverrideCount(), p.createdAt()); } record Page(List content, int page, int size, long totalElements, diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ReorderApiConnectorVariablesRequest.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ReorderApiConnectorVariablesRequest.java new file mode 100644 index 00000000..361ca99d --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/ReorderApiConnectorVariablesRequest.java @@ -0,0 +1,17 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import com.bablsoft.accessflow.apigov.api.ReorderApiConnectorVariablesCommand; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; +import java.util.UUID; + +/** The connector's complete variable id list, in the desired evaluation order. */ +public record ReorderApiConnectorVariablesRequest( + @NotEmpty(message = "{validation.api_connector_variable.reorder.required}") + List variableIds) { + + public ReorderApiConnectorVariablesCommand toCommand() { + return new ReorderApiConnectorVariablesCommand(variableIds); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/SubmitApiRequestRequest.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/SubmitApiRequestRequest.java index 3d75a548..bd99e88a 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/SubmitApiRequestRequest.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/SubmitApiRequestRequest.java @@ -27,6 +27,7 @@ public record SubmitApiRequestRequest( String requestBody, List formFields, String binaryFilename, + Map variableOverrides, String justification, Instant scheduledFor, SubmissionReason submissionReason) { @@ -35,6 +36,7 @@ SubmitApiRequestCommand toCommand(UUID organizationId, UUID userId, boolean admi String userAgent) { return new SubmitApiRequestCommand(connectorId, organizationId, userId, admin, operationId, verb, requestPath, requestHeaders, queryParams, bodyType, requestContentType, requestBody, - formFields, binaryFilename, justification, scheduledFor, submissionReason, ip, userAgent); + formFields, binaryFilename, variableOverrides, justification, scheduledFor, + submissionReason, ip, userAgent); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorGroupPermissionRequest.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorGroupPermissionRequest.java index b3e6b55e..b8976b8e 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorGroupPermissionRequest.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorGroupPermissionRequest.java @@ -9,12 +9,15 @@ public record UpdateApiConnectorGroupPermissionRequest( boolean canRead, boolean canWrite, boolean canBreakGlass, + /** AF-613. Boxed so an existing client that omits it still works; null means false. */ + Boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields) { UpdateApiConnectorGroupPermissionCommand toCommand() { return new UpdateApiConnectorGroupPermissionCommand(canRead, canWrite, canBreakGlass, + Boolean.TRUE.equals(canOverrideVariables), expiresAt, allowedOperations, restrictedResponseFields); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorPermissionRequest.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorPermissionRequest.java index 4f3d8448..b7fedfa3 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorPermissionRequest.java +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorPermissionRequest.java @@ -9,12 +9,15 @@ public record UpdateApiConnectorPermissionRequest( boolean canRead, boolean canWrite, boolean canBreakGlass, + /** AF-613. Boxed so an existing client that omits it still works; null means false. */ + Boolean canOverrideVariables, Instant expiresAt, List allowedOperations, List restrictedResponseFields) { UpdateApiConnectorPermissionCommand toCommand() { return new UpdateApiConnectorPermissionCommand(canRead, canWrite, canBreakGlass, + Boolean.TRUE.equals(canOverrideVariables), expiresAt, allowedOperations, restrictedResponseFields); } } diff --git a/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorVariableRequest.java b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorVariableRequest.java new file mode 100644 index 00000000..127ef71e --- /dev/null +++ b/backend/src/main/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorVariableRequest.java @@ -0,0 +1,41 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import com.bablsoft.accessflow.apigov.api.UpdateApiConnectorVariableCommand; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * {@code secret} is write-only and tri-state: omitted leaves the stored value alone, a non-blank + * value replaces it, and {@code clearSecret} removes it. + */ +public record UpdateApiConnectorVariableRequest( + @NotBlank(message = "{validation.api_connector_variable.name.required}") + @Pattern(regexp = "^[A-Za-z][A-Za-z0-9_]{0,63}$", + message = "{validation.api_connector_variable.name.invalid}") + String name, + @NotNull(message = "{validation.api_connector_variable.kind.required}") + ApiVariableKind kind, + @Size(max = 8192, message = "{validation.api_connector_variable.expression.too_long}") + String expression, + ApiVariableAlgorithm algorithm, + ApiVariableEncoding encoding, + @Size(max = 4096, message = "{validation.api_connector_variable.secret.too_long}") + String secret, + Boolean clearSecret, + @Size(max = 140, message = "{validation.api_connector_variable.target.invalid}") + String target, + Boolean overridable, + @Size(max = 512, message = "{validation.api_connector_variable.description.too_long}") + String description, + Integer sortOrder) { + + public UpdateApiConnectorVariableCommand toCommand() { + return new UpdateApiConnectorVariableCommand(name, kind, expression, algorithm, encoding, + secret, clearSecret, target, overridable, description, sortOrder); + } +} diff --git a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java index 58758e51..392df831 100644 --- a/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java +++ b/backend/src/main/java/com/bablsoft/accessflow/audit/api/AuditAction.java @@ -145,6 +145,10 @@ public enum AuditAction { API_CONNECTOR_MASKING_POLICY_DELETED, API_CONNECTOR_CLASSIFICATION_TAG_ADDED, API_CONNECTOR_CLASSIFICATION_TAG_REMOVED, + API_CONNECTOR_VARIABLE_CREATED, + API_CONNECTOR_VARIABLE_UPDATED, + API_CONNECTOR_VARIABLE_DELETED, + API_CONNECTOR_VARIABLES_REORDERED, RETENTION_POLICY_CREATED, RETENTION_POLICY_UPDATED, diff --git a/backend/src/main/resources/db/migration/V121__api_connector_variables.sql b/backend/src/main/resources/db/migration/V121__api_connector_variables.sql new file mode 100644 index 00000000..1be42f41 --- /dev/null +++ b/backend/src/main/resources/db/migration/V121__api_connector_variables.sql @@ -0,0 +1,81 @@ +-- AF-613: dynamic variables for API connectors. An API connector could previously only send static +-- values — default_headers is a fixed map and ApiConnectorAuthApplier builds one of a fixed set of +-- auth headers — which rules out every API whose contract requires a value computed per request: +-- HMAC request signing, nonces, timestamps, correlation ids, idempotency keys, digests. A submitter +-- cannot hand-compute those for a governed request, because the signature covers a body the reviewer +-- saw minutes or hours earlier and any timestamp would be stale by execution time. +-- +-- A variable is a named expression evaluated at execution time and substituted into headers, path, +-- query and body via {{name}} placeholders. Evaluation happens AFTER auth headers are resolved +-- (including a freshly minted OAuth2 token), so an expression may sign the finished Authorization +-- header — the motivating vendor scheme. Expressions are template substitution over a fixed function +-- set only: no scripting engine, no eval, mirroring the engine plugins' rejection of $where/Painless. +-- +-- Ordering deliberately deviates from the AF-518 masking child-table idiom (ORDER BY created_at). +-- Masking policies are order-insensitive (all apply); variable evaluation order is observable, and +-- created_at defaults to CURRENT_TIMESTAMP which is transaction-constant in Postgres — two rows +-- inserted in one transaction would tie exactly. Hence an explicit sort_order, with (sort_order, +-- created_at, id) forming a total order for the topological tie-break. + +CREATE TYPE api_variable_kind AS ENUM ( + 'UUID', 'TIMESTAMP', 'EPOCH_MILLIS', 'RANDOM_HEX', 'HMAC', 'HASH', 'ENCODE', 'CONSTANT' +); + +CREATE TYPE api_variable_algorithm AS ENUM ('HMAC_SHA256', 'HMAC_SHA512', 'SHA256', 'MD5'); + +CREATE TYPE api_variable_encoding AS ENUM ('HEX', 'BASE64', 'BASE64URL'); + +-- Per-connector, org-scoped dynamic variables. `expression` is the input template for the value and +-- is itself placeholder-expanded against the in-flight request ({{request.method|path|query|body}}, +-- {{request.headers.}}) and other variables ({{var.}}); resolution order is a DAG over +-- those references, with cycles rejected at save time rather than at execution time. `target` +-- optionally auto-injects the resolved value without an explicit placeholder: 'header:' or +-- 'query:', applied after substitution. `secret_encrypted` holds the AES-256-GCM shared key +-- for kind=HMAC and follows the same rules as api_connectors.auth_credentials_encrypted — @JsonIgnore +-- on the entity, never returned in a GET, never logged. +-- +-- `overridable` opts a variable into per-request overrides (see api_requests.variable_overrides). +-- Deny by default. The CHECK constraint is belt-and-braces with the service-layer validation: a +-- secret-bearing variable must never be overridable, and that rule must survive a manual data fix. +CREATE TABLE api_connector_variables ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL, + connector_id UUID NOT NULL REFERENCES api_connectors(id) ON DELETE CASCADE, + name TEXT NOT NULL, + kind api_variable_kind NOT NULL, + expression TEXT, + algorithm api_variable_algorithm, + encoding api_variable_encoding, + secret_encrypted TEXT, + target TEXT, + overridable BOOLEAN NOT NULL DEFAULT false, + description TEXT, + sort_order INT NOT NULL DEFAULT 0, + version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT ck_acv_override_no_secret CHECK (NOT (overridable AND secret_encrypted IS NOT NULL)) +); + +-- Names are the placeholder keys, so they must be unique per connector or {{name}} is ambiguous. +CREATE UNIQUE INDEX uq_api_connector_variable_name + ON api_connector_variables (organization_id, connector_id, name); + +-- Backs the per-execution resolution scan, already in evaluation order. +CREATE INDEX idx_api_connector_variable_connector + ON api_connector_variables (organization_id, connector_id, sort_order); + +-- Per-request overrides of connector variables marked overridable. Persisted (not transient) because +-- reviewers approve based on stored state and the submit -> review -> execute gap is asynchronous: +-- an override that wasn't stored and shown at review time would let a submitter change the effective +-- request after approval. Immutable after submit. Values are inserted verbatim and never rendered as +-- templates, so an override can never expand into another variable's value. +ALTER TABLE api_requests ADD COLUMN variable_overrides JSONB NOT NULL DEFAULT '{}'; + +-- Supplying overrides is a distinct capability from submitting, gated per connector exactly like +-- can_break_glass and OR-merged across user + group grants in EffectiveApiConnectorPermissionResolver. +ALTER TABLE api_connector_user_permissions + ADD COLUMN can_override_variables BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE api_connector_group_permissions + ADD COLUMN can_override_variables BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/src/main/resources/i18n/messages.properties b/backend/src/main/resources/i18n/messages.properties index 199a2d41..e91dac18 100644 --- a/backend/src/main/resources/i18n/messages.properties +++ b/backend/src/main/resources/i18n/messages.properties @@ -812,6 +812,40 @@ error.api_classification_tag_matcher_required=A classification tag must have a m error.api_classification_tag_operation_required=A schema-field classification tag must target an operation error.api_classification_tag_field_required=A classification tag must target a field error.api_classification_tag_classifications_required=At least one classification is required +# API connector dynamic variables (AF-613) +validation.api_connector_variable.name.required=A variable name is required +validation.api_connector_variable.name.invalid=A variable name must start with a letter and contain only letters, digits and underscores (max 64 characters) +validation.api_connector_variable.kind.required=A variable kind is required +validation.api_connector_variable.expression.too_long=The expression must be at most 8,192 characters +validation.api_connector_variable.secret.too_long=The secret must be at most 4,096 characters +validation.api_connector_variable.target.invalid=The target must be at most 140 characters +validation.api_connector_variable.description.too_long=The description must be at most 512 characters +validation.api_connector_variable.reorder.required=The variable order must list at least one variable +error.api_connector_variable_not_found=API connector variable not found +error.api_connector_variable_name_required=A variable name is required +error.api_connector_variable_name_invalid=Invalid variable name: {0} +error.api_connector_variable_name_duplicate=A variable named {0} already exists on this connector +error.api_connector_variable_kind_required=A variable kind is required +error.api_connector_variable_expression_required=Variable {0} requires an expression +error.api_connector_variable_expression_forbidden=Variable {0} must not have an expression +error.api_connector_variable_algorithm_invalid=Variable {0} has an algorithm that does not match its kind +error.api_connector_variable_algorithm_forbidden=Variable {0} must not specify an algorithm +error.api_connector_variable_encoding_required=Variable {0} requires an encoding +error.api_connector_variable_encoding_forbidden=Variable {0} must not specify an encoding +error.api_connector_variable_secret_required=Variable {0} requires a shared secret +error.api_connector_variable_secret_forbidden=Variable {0} must not have a shared secret +error.api_connector_variable_target_invalid=Variable {0} has an invalid target; use header:Name or query:name +error.api_connector_variable_target_duplicate=Another variable already injects into {0} +error.api_connector_variable_cycle=These variables reference each other in a cycle: {0} +error.api_connector_variable_unknown_reference=Variable {0} references unknown variable {1} +error.api_connector_variable_referenced=Variable {0} cannot be removed or renamed because variable {1} references it +error.api_connector_variable_too_many=A connector may define at most {0} variables +error.api_connector_variable_overridable_secret=A variable that holds a secret cannot be made overridable +error.api_connector_variable_reorder_incomplete=The variable order must list every variable on this connector exactly once +error.api_connector_variable_random_hex_size=Variable {0} requires a byte count between {1} and {2} +error.api_connector_variable_timestamp_pattern=Variable {0} has an invalid timestamp pattern +error.api_connector_variable_value_too_large=Variable {0} resolved to a value larger than {1} bytes +error.api_connector_variable_value_invalid=Variable {0} resolved to a value containing line breaks or null characters # Lifecycle (AF-499) validation.lifecycle.datasource.required=Datasource is required diff --git a/backend/src/main/resources/i18n/messages_de.properties b/backend/src/main/resources/i18n/messages_de.properties index 9b693855..6dc04a07 100644 --- a/backend/src/main/resources/i18n/messages_de.properties +++ b/backend/src/main/resources/i18n/messages_de.properties @@ -795,6 +795,40 @@ error.api_classification_tag_matcher_required=Ein Klassifizierungs-Tag muss eine error.api_classification_tag_operation_required=Ein Schemafeld-Klassifizierungs-Tag muss auf eine Operation verweisen error.api_classification_tag_field_required=Ein Klassifizierungs-Tag muss auf ein Feld verweisen error.api_classification_tag_classifications_required=Mindestens eine Klassifizierung ist erforderlich +# API-Connector-Variablen (AF-613) +validation.api_connector_variable.name.required=Ein Variablenname ist erforderlich +validation.api_connector_variable.name.invalid=Ein Variablenname muss mit einem Buchstaben beginnen und darf nur Buchstaben, Ziffern und Unterstriche enthalten (max. 64 Zeichen) +validation.api_connector_variable.kind.required=Ein Variablentyp ist erforderlich +validation.api_connector_variable.expression.too_long=Der Ausdruck darf hoechstens 8.192 Zeichen lang sein +validation.api_connector_variable.secret.too_long=Das Geheimnis darf hoechstens 4.096 Zeichen lang sein +validation.api_connector_variable.target.invalid=Das Ziel darf hoechstens 140 Zeichen lang sein +validation.api_connector_variable.description.too_long=Die Beschreibung darf hoechstens 512 Zeichen lang sein +validation.api_connector_variable.reorder.required=Die Reihenfolge muss mindestens eine Variable enthalten +error.api_connector_variable_not_found=API-Connector-Variable nicht gefunden +error.api_connector_variable_name_required=Ein Variablenname ist erforderlich +error.api_connector_variable_name_invalid=Ungueltiger Variablenname: {0} +error.api_connector_variable_name_duplicate=Eine Variable namens {0} existiert bereits fuer diesen Connector +error.api_connector_variable_kind_required=Ein Variablentyp ist erforderlich +error.api_connector_variable_expression_required=Variable {0} benoetigt einen Ausdruck +error.api_connector_variable_expression_forbidden=Variable {0} darf keinen Ausdruck haben +error.api_connector_variable_algorithm_invalid=Der Algorithmus von Variable {0} passt nicht zu ihrem Typ +error.api_connector_variable_algorithm_forbidden=Variable {0} darf keinen Algorithmus angeben +error.api_connector_variable_encoding_required=Variable {0} benoetigt eine Kodierung +error.api_connector_variable_encoding_forbidden=Variable {0} darf keine Kodierung angeben +error.api_connector_variable_secret_required=Variable {0} benoetigt ein gemeinsames Geheimnis +error.api_connector_variable_secret_forbidden=Variable {0} darf kein gemeinsames Geheimnis haben +error.api_connector_variable_target_invalid=Variable {0} hat ein ungueltiges Ziel; verwenden Sie header:Name oder query:name +error.api_connector_variable_target_duplicate=Eine andere Variable schreibt bereits nach {0} +error.api_connector_variable_cycle=Diese Variablen verweisen zyklisch aufeinander: {0} +error.api_connector_variable_unknown_reference=Variable {0} verweist auf die unbekannte Variable {1} +error.api_connector_variable_referenced=Variable {0} kann nicht entfernt oder umbenannt werden, da Variable {1} darauf verweist +error.api_connector_variable_too_many=Ein Connector darf hoechstens {0} Variablen definieren +error.api_connector_variable_overridable_secret=Eine Variable mit einem Geheimnis kann nicht ueberschreibbar gemacht werden +error.api_connector_variable_reorder_incomplete=Die Reihenfolge muss jede Variable dieses Connectors genau einmal enthalten +error.api_connector_variable_random_hex_size=Variable {0} benoetigt eine Byte-Anzahl zwischen {1} und {2} +error.api_connector_variable_timestamp_pattern=Variable {0} hat ein ungueltiges Zeitstempelmuster +error.api_connector_variable_value_too_large=Variable {0} ergab einen Wert groesser als {1} Byte +error.api_connector_variable_value_invalid=Variable {0} ergab einen Wert mit Zeilenumbruechen oder Nullzeichen # Lifecycle (AF-499) validation.lifecycle.datasource.required=Datenquelle ist erforderlich diff --git a/backend/src/main/resources/i18n/messages_es.properties b/backend/src/main/resources/i18n/messages_es.properties index 7f8ff815..74bd3722 100644 --- a/backend/src/main/resources/i18n/messages_es.properties +++ b/backend/src/main/resources/i18n/messages_es.properties @@ -795,6 +795,40 @@ error.api_classification_tag_matcher_required=Una etiqueta de clasificación deb error.api_classification_tag_operation_required=Una etiqueta de clasificación de campo de esquema debe apuntar a una operación error.api_classification_tag_field_required=Una etiqueta de clasificación debe apuntar a un campo error.api_classification_tag_classifications_required=Se requiere al menos una clasificación +# Variables dinamicas de conectores de API (AF-613) +validation.api_connector_variable.name.required=Se requiere un nombre de variable +validation.api_connector_variable.name.invalid=Un nombre de variable debe empezar por una letra y contener solo letras, digitos y guiones bajos (max. 64 caracteres) +validation.api_connector_variable.kind.required=Se requiere un tipo de variable +validation.api_connector_variable.expression.too_long=La expresion debe tener como maximo 8.192 caracteres +validation.api_connector_variable.secret.too_long=El secreto debe tener como maximo 4.096 caracteres +validation.api_connector_variable.target.invalid=El destino debe tener como maximo 140 caracteres +validation.api_connector_variable.description.too_long=La descripcion debe tener como maximo 512 caracteres +validation.api_connector_variable.reorder.required=El orden debe incluir al menos una variable +error.api_connector_variable_not_found=Variable de conector de API no encontrada +error.api_connector_variable_name_required=Se requiere un nombre de variable +error.api_connector_variable_name_invalid=Nombre de variable no valido: {0} +error.api_connector_variable_name_duplicate=Ya existe una variable llamada {0} en este conector +error.api_connector_variable_kind_required=Se requiere un tipo de variable +error.api_connector_variable_expression_required=La variable {0} requiere una expresion +error.api_connector_variable_expression_forbidden=La variable {0} no debe tener expresion +error.api_connector_variable_algorithm_invalid=El algoritmo de la variable {0} no corresponde a su tipo +error.api_connector_variable_algorithm_forbidden=La variable {0} no debe especificar un algoritmo +error.api_connector_variable_encoding_required=La variable {0} requiere una codificacion +error.api_connector_variable_encoding_forbidden=La variable {0} no debe especificar una codificacion +error.api_connector_variable_secret_required=La variable {0} requiere un secreto compartido +error.api_connector_variable_secret_forbidden=La variable {0} no debe tener un secreto compartido +error.api_connector_variable_target_invalid=La variable {0} tiene un destino no valido; use header:Nombre o query:nombre +error.api_connector_variable_target_duplicate=Otra variable ya inyecta en {0} +error.api_connector_variable_cycle=Estas variables se referencian en ciclo: {0} +error.api_connector_variable_unknown_reference=La variable {0} referencia la variable desconocida {1} +error.api_connector_variable_referenced=La variable {0} no se puede eliminar ni renombrar porque la variable {1} la referencia +error.api_connector_variable_too_many=Un conector puede definir como maximo {0} variables +error.api_connector_variable_overridable_secret=Una variable que contiene un secreto no puede hacerse sustituible +error.api_connector_variable_reorder_incomplete=El orden debe incluir cada variable de este conector exactamente una vez +error.api_connector_variable_random_hex_size=La variable {0} requiere un numero de bytes entre {1} y {2} +error.api_connector_variable_timestamp_pattern=La variable {0} tiene un patron de marca de tiempo no valido +error.api_connector_variable_value_too_large=La variable {0} produjo un valor mayor de {1} bytes +error.api_connector_variable_value_invalid=La variable {0} produjo un valor con saltos de linea o caracteres nulos # Lifecycle (AF-499) validation.lifecycle.datasource.required=La fuente de datos es obligatoria diff --git a/backend/src/main/resources/i18n/messages_fr.properties b/backend/src/main/resources/i18n/messages_fr.properties index fbe536bf..a35dde09 100644 --- a/backend/src/main/resources/i18n/messages_fr.properties +++ b/backend/src/main/resources/i18n/messages_fr.properties @@ -798,6 +798,40 @@ error.api_classification_tag_matcher_required=Une étiquette de classification d error.api_classification_tag_operation_required=Une étiquette de classification de champ de schéma doit cibler une opération error.api_classification_tag_field_required=Une étiquette de classification doit cibler un champ error.api_classification_tag_classifications_required=Au moins une classification est requise +# Variables dynamiques des connecteurs API (AF-613) +validation.api_connector_variable.name.required=Un nom de variable est requis +validation.api_connector_variable.name.invalid=Un nom de variable doit commencer par une lettre et ne contenir que des lettres, chiffres et tirets bas (64 caracteres maximum) +validation.api_connector_variable.kind.required=Un type de variable est requis +validation.api_connector_variable.expression.too_long=Lexpression ne doit pas depasser 8 192 caracteres +validation.api_connector_variable.secret.too_long=Le secret ne doit pas depasser 4 096 caracteres +validation.api_connector_variable.target.invalid=La cible ne doit pas depasser 140 caracteres +validation.api_connector_variable.description.too_long=La description ne doit pas depasser 512 caracteres +validation.api_connector_variable.reorder.required=Lordre doit contenir au moins une variable +error.api_connector_variable_not_found=Variable de connecteur API introuvable +error.api_connector_variable_name_required=Un nom de variable est requis +error.api_connector_variable_name_invalid=Nom de variable invalide : {0} +error.api_connector_variable_name_duplicate=Une variable nommee {0} existe deja sur ce connecteur +error.api_connector_variable_kind_required=Un type de variable est requis +error.api_connector_variable_expression_required=La variable {0} necessite une expression +error.api_connector_variable_expression_forbidden=La variable {0} ne doit pas avoir dexpression +error.api_connector_variable_algorithm_invalid=Lalgorithme de la variable {0} ne correspond pas a son type +error.api_connector_variable_algorithm_forbidden=La variable {0} ne doit pas specifier dalgorithme +error.api_connector_variable_encoding_required=La variable {0} necessite un encodage +error.api_connector_variable_encoding_forbidden=La variable {0} ne doit pas specifier dencodage +error.api_connector_variable_secret_required=La variable {0} necessite un secret partage +error.api_connector_variable_secret_forbidden=La variable {0} ne doit pas avoir de secret partage +error.api_connector_variable_target_invalid=La variable {0} a une cible invalide ; utilisez header:Nom ou query:nom +error.api_connector_variable_target_duplicate=Une autre variable injecte deja dans {0} +error.api_connector_variable_cycle=Ces variables se referencent en cycle : {0} +error.api_connector_variable_unknown_reference=La variable {0} reference la variable inconnue {1} +error.api_connector_variable_referenced=La variable {0} ne peut etre supprimee ni renommee car la variable {1} la reference +error.api_connector_variable_too_many=Un connecteur peut definir au maximum {0} variables +error.api_connector_variable_overridable_secret=Une variable contenant un secret ne peut pas etre rendue substituable +error.api_connector_variable_reorder_incomplete=Lordre doit contenir chaque variable de ce connecteur exactement une fois +error.api_connector_variable_random_hex_size=La variable {0} necessite un nombre doctets entre {1} et {2} +error.api_connector_variable_timestamp_pattern=La variable {0} a un format dhorodatage invalide +error.api_connector_variable_value_too_large=La variable {0} a produit une valeur superieure a {1} octets +error.api_connector_variable_value_invalid=La variable {0} a produit une valeur contenant des sauts de ligne ou des caracteres nuls # Lifecycle (AF-499) validation.lifecycle.datasource.required=La source de données est obligatoire diff --git a/backend/src/main/resources/i18n/messages_hy.properties b/backend/src/main/resources/i18n/messages_hy.properties index 1247f972..39287564 100644 --- a/backend/src/main/resources/i18n/messages_hy.properties +++ b/backend/src/main/resources/i18n/messages_hy.properties @@ -795,6 +795,40 @@ error.api_classification_tag_matcher_required=Դասակարգման պիտակ error.api_classification_tag_operation_required=Սխեմայի դաշտի դասակարգման պիտակը պետք է թիրախավորի գործողություն error.api_classification_tag_field_required=Դասակարգման պիտակը պետք է թիրախավորի դաշտ error.api_classification_tag_classifications_required=Պահանջվում է առնվազն մեկ դասակարգում +# API միակցիչի դինամիկ փոփոխականներ (AF-613) +validation.api_connector_variable.name.required=Փոփոխականի անունը պարտադիր է +validation.api_connector_variable.name.invalid=Փոփոխականի անունը պետք է սկսվի տառով և պարունակի միայն տառեր, թվեր և ընդգծումներ (առավելագույնը 64 նիշ) +validation.api_connector_variable.kind.required=Փոփոխականի տեսակը պարտադիր է +validation.api_connector_variable.expression.too_long=Արտահայտությունը պետք է լինի առավելագույնը 8192 նիշ +validation.api_connector_variable.secret.too_long=Գաղտնիքը պետք է լինի առավելագույնը 4096 նիշ +validation.api_connector_variable.target.invalid=Նպատակը պետք է լինի առավելագույնը 140 նիշ +validation.api_connector_variable.description.too_long=Նկարագրությունը պետք է լինի առավելագույնը 512 նիշ +validation.api_connector_variable.reorder.required=Հերթականությունը պետք է պարունակի առնվազն մեկ փոփոխական +error.api_connector_variable_not_found=API միակցիչի փոփոխականը չի գտնվել +error.api_connector_variable_name_required=Փոփոխականի անունը պարտադիր է +error.api_connector_variable_name_invalid=Անվավեր փոփոխականի անուն՝ {0} +error.api_connector_variable_name_duplicate={0} անունով փոփոխականն արդեն գոյություն ունի այս միակցիչում +error.api_connector_variable_kind_required=Փոփոխականի տեսակը պարտադիր է +error.api_connector_variable_expression_required={0} փոփոխականը պահանջում է արտահայտություն +error.api_connector_variable_expression_forbidden={0} փոփոխականը չպետք է ունենա արտահայտություն +error.api_connector_variable_algorithm_invalid={0} փոփոխականի ալգորիթմը չի համապատասխանում իր տեսակին +error.api_connector_variable_algorithm_forbidden={0} փոփոխականը չպետք է նշի ալգորիթմ +error.api_connector_variable_encoding_required={0} փոփոխականը պահանջում է կոդավորում +error.api_connector_variable_encoding_forbidden={0} փոփոխականը չպետք է նշի կոդավորում +error.api_connector_variable_secret_required={0} փոփոխականը պահանջում է ընդհանուր գաղտնիք +error.api_connector_variable_secret_forbidden={0} փոփոխականը չպետք է ունենա ընդհանուր գաղտնիք +error.api_connector_variable_target_invalid={0} փոփոխականն ունի անվավեր նպատակ. օգտագործեք header:Անուն կամ query:անուն +error.api_connector_variable_target_duplicate=Մեկ այլ փոփոխական արդեն ներարկում է {0}-ի մեջ +error.api_connector_variable_cycle=Այս փոփոխականները ցիկլային կերպով հղում են միմյանց՝ {0} +error.api_connector_variable_unknown_reference={0} փոփոխականը հղում է անհայտ {1} փոփոխականին +error.api_connector_variable_referenced={0} փոփոխականը հնարավոր չէ հեռացնել կամ վերանվանել, քանի որ {1} փոփոխականը հղում է դրան +error.api_connector_variable_too_many=Միակցիչը կարող է սահմանել առավելագույնը {0} փոփոխական +error.api_connector_variable_overridable_secret=Գաղտնիք պարունակող փոփոխականը հնարավոր չէ դարձնել վերասահմանելի +error.api_connector_variable_reorder_incomplete=Հերթականությունը պետք է պարունակի այս միակցիչի յուրաքանչյուր փոփոխական ուղիղ մեկ անգամ +error.api_connector_variable_random_hex_size={0} փոփոխականը պահանջում է բայթերի քանակ {1}-ի և {2}-ի միջև +error.api_connector_variable_timestamp_pattern={0} փոփոխականն ունի անվավեր ժամանակի ձևաչափ +error.api_connector_variable_value_too_large={0} փոփոխականը տվել է {1} բայթից մեծ արժեք +error.api_connector_variable_value_invalid={0} փոփոխականը տվել է տողադարձեր կամ զրոյական նիշեր պարունակող արժեք # Lifecycle (AF-499) validation.lifecycle.datasource.required=Տվյալների աղբյուրը պարտադիր է diff --git a/backend/src/main/resources/i18n/messages_ru.properties b/backend/src/main/resources/i18n/messages_ru.properties index e7c0375e..b1404172 100644 --- a/backend/src/main/resources/i18n/messages_ru.properties +++ b/backend/src/main/resources/i18n/messages_ru.properties @@ -795,6 +795,40 @@ error.api_classification_tag_matcher_required=Тег классификации error.api_classification_tag_operation_required=Тег классификации поля схемы должен указывать на операцию error.api_classification_tag_field_required=Тег классификации должен указывать на поле error.api_classification_tag_classifications_required=Требуется хотя бы одна классификация +# Динамические переменные API-коннектора (AF-613) +validation.api_connector_variable.name.required=Требуется имя переменной +validation.api_connector_variable.name.invalid=Имя переменной должно начинаться с буквы и содержать только буквы, цифры и подчёркивания (не более 64 символов) +validation.api_connector_variable.kind.required=Требуется тип переменной +validation.api_connector_variable.expression.too_long=Выражение не должно превышать 8 192 символов +validation.api_connector_variable.secret.too_long=Секрет не должен превышать 4 096 символов +validation.api_connector_variable.target.invalid=Цель не должна превышать 140 символов +validation.api_connector_variable.description.too_long=Описание не должно превышать 512 символов +validation.api_connector_variable.reorder.required=Порядок должен содержать хотя бы одну переменную +error.api_connector_variable_not_found=Переменная API-коннектора не найдена +error.api_connector_variable_name_required=Требуется имя переменной +error.api_connector_variable_name_invalid=Недопустимое имя переменной: {0} +error.api_connector_variable_name_duplicate=Переменная с именем {0} уже существует в этом коннекторе +error.api_connector_variable_kind_required=Требуется тип переменной +error.api_connector_variable_expression_required=Переменной {0} требуется выражение +error.api_connector_variable_expression_forbidden=Переменная {0} не должна иметь выражения +error.api_connector_variable_algorithm_invalid=Алгоритм переменной {0} не соответствует её типу +error.api_connector_variable_algorithm_forbidden=Переменная {0} не должна задавать алгоритм +error.api_connector_variable_encoding_required=Переменной {0} требуется кодировка +error.api_connector_variable_encoding_forbidden=Переменная {0} не должна задавать кодировку +error.api_connector_variable_secret_required=Переменной {0} требуется общий секрет +error.api_connector_variable_secret_forbidden=Переменная {0} не должна иметь общего секрета +error.api_connector_variable_target_invalid=У переменной {0} недопустимая цель; используйте header:Имя или query:имя +error.api_connector_variable_target_duplicate=Другая переменная уже подставляется в {0} +error.api_connector_variable_cycle=Эти переменные ссылаются друг на друга циклически: {0} +error.api_connector_variable_unknown_reference=Переменная {0} ссылается на неизвестную переменную {1} +error.api_connector_variable_referenced=Переменную {0} нельзя удалить или переименовать, так как на неё ссылается переменная {1} +error.api_connector_variable_too_many=Коннектор может определять не более {0} переменных +error.api_connector_variable_overridable_secret=Переменную, содержащую секрет, нельзя сделать переопределяемой +error.api_connector_variable_reorder_incomplete=Порядок должен содержать каждую переменную этого коннектора ровно один раз +error.api_connector_variable_random_hex_size=Переменной {0} требуется количество байт от {1} до {2} +error.api_connector_variable_timestamp_pattern=У переменной {0} недопустимый формат метки времени +error.api_connector_variable_value_too_large=Переменная {0} дала значение больше {1} байт +error.api_connector_variable_value_invalid=Переменная {0} дала значение с переводами строк или нулевыми символами # Lifecycle (AF-499) validation.lifecycle.datasource.required=Источник данных обязателен diff --git a/backend/src/main/resources/i18n/messages_zh_CN.properties b/backend/src/main/resources/i18n/messages_zh_CN.properties index b03803fe..db1aee21 100644 --- a/backend/src/main/resources/i18n/messages_zh_CN.properties +++ b/backend/src/main/resources/i18n/messages_zh_CN.properties @@ -795,6 +795,40 @@ error.api_classification_tag_matcher_required=分类标签必须具有匹配类 error.api_classification_tag_operation_required=架构字段分类标签必须指向某个操作 error.api_classification_tag_field_required=分类标签必须指向某个字段 error.api_classification_tag_classifications_required=至少需要一种分类 +# API 连接器动态变量 (AF-613) +validation.api_connector_variable.name.required=必须提供变量名称 +validation.api_connector_variable.name.invalid=变量名称必须以字母开头,且只能包含字母、数字和下划线(最多 64 个字符) +validation.api_connector_variable.kind.required=必须提供变量类型 +validation.api_connector_variable.expression.too_long=表达式最多 8,192 个字符 +validation.api_connector_variable.secret.too_long=密钥最多 4,096 个字符 +validation.api_connector_variable.target.invalid=目标最多 140 个字符 +validation.api_connector_variable.description.too_long=描述最多 512 个字符 +validation.api_connector_variable.reorder.required=顺序中必须至少包含一个变量 +error.api_connector_variable_not_found=未找到 API 连接器变量 +error.api_connector_variable_name_required=必须提供变量名称 +error.api_connector_variable_name_invalid=变量名称无效:{0} +error.api_connector_variable_name_duplicate=该连接器上已存在名为 {0} 的变量 +error.api_connector_variable_kind_required=必须提供变量类型 +error.api_connector_variable_expression_required=变量 {0} 需要表达式 +error.api_connector_variable_expression_forbidden=变量 {0} 不得包含表达式 +error.api_connector_variable_algorithm_invalid=变量 {0} 的算法与其类型不匹配 +error.api_connector_variable_algorithm_forbidden=变量 {0} 不得指定算法 +error.api_connector_variable_encoding_required=变量 {0} 需要编码方式 +error.api_connector_variable_encoding_forbidden=变量 {0} 不得指定编码方式 +error.api_connector_variable_secret_required=变量 {0} 需要共享密钥 +error.api_connector_variable_secret_forbidden=变量 {0} 不得包含共享密钥 +error.api_connector_variable_target_invalid=变量 {0} 的目标无效;请使用 header:名称 或 query:名称 +error.api_connector_variable_target_duplicate=已有其他变量注入到 {0} +error.api_connector_variable_cycle=以下变量存在循环引用:{0} +error.api_connector_variable_unknown_reference=变量 {0} 引用了未知变量 {1} +error.api_connector_variable_referenced=变量 {0} 无法删除或重命名,因为变量 {1} 引用了它 +error.api_connector_variable_too_many=一个连接器最多可定义 {0} 个变量 +error.api_connector_variable_overridable_secret=包含密钥的变量不能设为可覆盖 +error.api_connector_variable_reorder_incomplete=顺序中必须恰好包含该连接器的每个变量一次 +error.api_connector_variable_random_hex_size=变量 {0} 需要介于 {1} 和 {2} 之间的字节数 +error.api_connector_variable_timestamp_pattern=变量 {0} 的时间戳格式无效 +error.api_connector_variable_value_too_large=变量 {0} 解析出的值超过 {1} 字节 +error.api_connector_variable_value_invalid=变量 {0} 解析出的值包含换行符或空字符 # Lifecycle (AF-499) validation.lifecycle.datasource.required=数据源为必填项 diff --git a/backend/src/test/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializerTest.java b/backend/src/test/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializerTest.java index 06fbe78c..8d9533cc 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/access/internal/AccessGrantMaterializerTest.java @@ -146,7 +146,7 @@ private AccessGrantRequestEntity approvedConnector() { private ApiConnectorPermissionView connectorGranted() { return new ApiConnectorPermissionView(newPermissionId, connectorId, requesterId, "u@x.io", - "U", true, true, false, Instant.now().plusSeconds(3600), List.of("listCharges"), + "U", true, true, false, false, Instant.now().plusSeconds(3600), List.of("listCharges"), null, Instant.now()); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiConnectorVariablesIntegrationTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiConnectorVariablesIntegrationTest.java new file mode 100644 index 00000000..73802b04 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiConnectorVariablesIntegrationTest.java @@ -0,0 +1,259 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.TestcontainersConfig; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableAdminService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableLookupService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableResolutionService; +import com.bablsoft.accessflow.apigov.api.ApiProtocol; +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import com.bablsoft.accessflow.apigov.api.ApiVariableRequestContext; +import com.bablsoft.accessflow.apigov.api.ApiVariableTargetType; +import com.bablsoft.accessflow.apigov.api.CreateApiConnectorVariableCommand; +import com.bablsoft.accessflow.apigov.api.IllegalApiConnectorVariableException; +import com.bablsoft.accessflow.apigov.api.ReorderApiConnectorVariablesCommand; +import com.bablsoft.accessflow.apigov.api.UpdateApiConnectorVariableCommand; +import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiConnectorEntity; +import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorRepository; +import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorVariableRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.context.ImportTestcontainers; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateCrtKey; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Persistence + service integration for AF-613 against a real Postgres: the {@code api_variable_*} + * enum columns, the unique name index, the overridable/secret CHECK constraint, and the full + * resolve-and-sign path including the motivating vendor HMAC scheme. + */ +@SpringBootTest +@ImportTestcontainers(TestcontainersConfig.class) +class ApiConnectorVariablesIntegrationTest { + + @Autowired private ApiConnectorRepository connectorRepository; + @Autowired private ApiConnectorVariableRepository variableRepository; + @Autowired private ApiConnectorVariableAdminService adminService; + @Autowired private ApiConnectorVariableResolutionService resolutionService; + @Autowired private ApiConnectorVariableLookupService lookupService; + + private final UUID orgId = UUID.randomUUID(); + + @DynamicPropertySource + static void securityProperties(DynamicPropertyRegistry registry) throws Exception { + var kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + var privateKey = (RSAPrivateCrtKey) kpg.generateKeyPair().getPrivate(); + var pem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(privateKey.getEncoded()) + + "\n-----END PRIVATE KEY-----"; + registry.add("accessflow.jwt.private-key", () -> pem); + registry.add("accessflow.encryption-key", () -> + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + } + + private UUID connector() { + var c = new ApiConnectorEntity(); + c.setId(UUID.randomUUID()); + c.setOrganizationId(orgId); + c.setName("conn-" + UUID.randomUUID()); + c.setProtocol(ApiProtocol.REST); + c.setBaseUrl("https://api.test"); + return connectorRepository.save(c).getId(); + } + + private static CreateApiConnectorVariableCommand constant(String name, String expression) { + return new CreateApiConnectorVariableCommand(name, ApiVariableKind.CONSTANT, expression, + null, null, null, null, null, null, null); + } + + @Test + void variableRoundTripsThroughPostgresWithItsEnumColumns() { + var connectorId = connector(); + + var created = adminService.create(connectorId, orgId, new CreateApiConnectorVariableCommand( + "signature", ApiVariableKind.HMAC, "{{request.body}}", + ApiVariableAlgorithm.HMAC_SHA512, ApiVariableEncoding.BASE64URL, "shared-key", + "header:X-Signature", false, "Vendor signature", null)); + + var listed = adminService.listForConnector(connectorId, orgId); + assertThat(listed).singleElement().satisfies(v -> { + assertThat(v.id()).isEqualTo(created.id()); + assertThat(v.kind()).isEqualTo(ApiVariableKind.HMAC); + assertThat(v.algorithm()).isEqualTo(ApiVariableAlgorithm.HMAC_SHA512); + assertThat(v.encoding()).isEqualTo(ApiVariableEncoding.BASE64URL); + assertThat(v.target()).isEqualTo("header:X-Signature"); + assertThat(v.hasSecret()).isTrue(); + }); + } + + /** The stored secret is AES-256-GCM ciphertext, never the plaintext key. */ + @Test + void theSecretIsEncryptedAtRest() { + var connectorId = connector(); + var created = adminService.create(connectorId, orgId, new CreateApiConnectorVariableCommand( + "sig", ApiVariableKind.HMAC, "{{request.body}}", ApiVariableAlgorithm.HMAC_SHA256, + ApiVariableEncoding.HEX, "shared-key", null, false, null, null)); + + var stored = variableRepository.findById(created.id()).orElseThrow(); + + assertThat(stored.getSecretEncrypted()).isNotNull().isNotEqualTo("shared-key"); + } + + @Test + void theUniqueNameIndexRejectsADuplicatePerConnector() { + var connectorId = connector(); + adminService.create(connectorId, orgId, constant("dup", "a")); + + assertThatThrownBy(() -> adminService.create(connectorId, orgId, constant("dup", "b"))) + .isInstanceOf(IllegalApiConnectorVariableException.class); + } + + @Test + void theSameNameIsAllowedOnADifferentConnector() { + adminService.create(connector(), orgId, constant("shared", "a")); + + assertThat(adminService.create(connector(), orgId, constant("shared", "b")).name()) + .isEqualTo("shared"); + } + + @Test + void updateAndDeleteRoundTrip() { + var connectorId = connector(); + var created = adminService.create(connectorId, orgId, constant("v", "one")); + + adminService.update(created.id(), connectorId, orgId, new UpdateApiConnectorVariableCommand( + "v", ApiVariableKind.CONSTANT, "two", null, null, null, null, null, null, "note", null)); + assertThat(adminService.listForConnector(connectorId, orgId).getFirst().expression()) + .isEqualTo("two"); + + adminService.delete(created.id(), connectorId, orgId); + assertThat(adminService.listForConnector(connectorId, orgId)).isEmpty(); + } + + @Test + void reorderPersistsTheNewEvaluationOrder() { + var connectorId = connector(); + var a = adminService.create(connectorId, orgId, constant("a", "1")); + var b = adminService.create(connectorId, orgId, constant("b", "2")); + + adminService.reorder(connectorId, orgId, + new ReorderApiConnectorVariablesCommand(List.of(b.id(), a.id()))); + + assertThat(adminService.listForConnector(connectorId, orgId)) + .extracting(v -> v.name()).containsExactly("b", "a"); + } + + /** + * The database enforces the overridable-implies-no-secret rule with a CHECK constraint, so it + * survives a manual data fix that bypasses the service. + */ + @Test + void theDatabaseRejectsAnOverridableSecretBearingRow() { + var connectorId = connector(); + var created = adminService.create(connectorId, orgId, new CreateApiConnectorVariableCommand( + "sig", ApiVariableKind.HMAC, "{{request.body}}", ApiVariableAlgorithm.HMAC_SHA256, + ApiVariableEncoding.HEX, "k", null, false, null, null)); + var entity = variableRepository.findById(created.id()).orElseThrow(); + entity.setOverridable(true); + + assertThatThrownBy(() -> variableRepository.saveAndFlush(entity)) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + } + + /** + * The motivating vendor scheme end to end, through real persistence and real AES decryption of + * the stored key: HMAC-SHA256 over the Authorization header concatenated with the body, where + * the body still carries the literal placeholder. + */ + @Test + void resolvesTheVendorHmacOverTheAuthHeaderAndPlaceholderBody() throws Exception { + var connectorId = connector(); + adminService.create(connectorId, orgId, new CreateApiConnectorVariableCommand("signature", + ApiVariableKind.HMAC, "{{request.headers.Authorization}}{{request.body}}", + ApiVariableAlgorithm.HMAC_SHA256, ApiVariableEncoding.HEX, "shared-key", null, + false, null, null)); + + var body = "{\"data\":\"example_data\",\"HMAC\":\"{{signature}}\"}"; + var authHeader = "Basic token_value"; + var resolved = resolutionService.resolve(orgId, connectorId, + new ApiVariableRequestContext("POST", "/pay", "", body, + Map.of("Authorization", authHeader)), + Map.of()); + + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec("shared-key".getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + var expected = HexFormat.of() + .formatHex(mac.doFinal((authHeader + body).getBytes(StandardCharsets.UTF_8))); + + assertThat(resolved.values()).containsEntry("signature", expected); + } + + @Test + void resolvesAChainInDependencyOrderAndEmitsItsTargets() { + var connectorId = connector(); + adminService.create(connectorId, orgId, new CreateApiConnectorVariableCommand("ts", + ApiVariableKind.EPOCH_MILLIS, null, null, null, null, "header:X-Timestamp", false, + null, null)); + adminService.create(connectorId, orgId, constant("payload", "v1|{{ts}}")); + + var resolved = resolutionService.resolve(orgId, connectorId, + new ApiVariableRequestContext("GET", "/x", "", "", Map.of()), Map.of()); + + assertThat(resolved.values().get("payload")).startsWith("v1|") + .isEqualTo("v1|" + resolved.values().get("ts")); + assertThat(resolved.injections()).singleElement().satisfies(i -> { + assertThat(i.type()).isEqualTo(ApiVariableTargetType.HEADER); + assertThat(i.key()).isEqualTo("X-Timestamp"); + }); + } + + @Test + void lookupServiceReportsOnlyTheOverridableNames() { + var connectorId = connector(); + adminService.create(connectorId, orgId, new CreateApiConnectorVariableCommand("nonce", + ApiVariableKind.RANDOM_HEX, null, null, null, null, null, true, "Client nonce", null)); + adminService.create(connectorId, orgId, constant("locked", "x")); + + assertThat(lookupService.overridableNames(connectorId, orgId)).containsExactly("nonce"); + assertThat(lookupService.summariesForConnector(connectorId, orgId)) + .extracting(s -> s.name()).containsExactly("nonce", "locked"); + } + + @Test + void anOverrideReplacesTheGeneratedValue() { + var connectorId = connector(); + adminService.create(connectorId, orgId, new CreateApiConnectorVariableCommand("nonce", + ApiVariableKind.RANDOM_HEX, null, null, null, null, null, true, null, null)); + + var resolved = resolutionService.resolve(orgId, connectorId, + new ApiVariableRequestContext("GET", "/x", "", "", Map.of()), + Map.of("nonce", "supplied")); + + assertThat(resolved.values()).containsEntry("nonce", "supplied"); + } + + @Test + void aConnectorInAnotherOrganizationSeesNoVariables() { + var connectorId = connector(); + adminService.create(connectorId, orgId, constant("v", "x")); + + assertThat(lookupService.summariesForConnector(connectorId, UUID.randomUUID())).isEmpty(); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiExecutionServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiExecutionServiceTest.java index 41ef709e..60799107 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiExecutionServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiExecutionServiceTest.java @@ -52,6 +52,9 @@ class ApiExecutionServiceTest { @Mock private ApiRequestStateService stateService; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private com.bablsoft.accessflow.apigov.api.ApiConnectorVariableResolutionService + variableResolutionService; + private ApiExecutionService service; private final UUID requestId = UUID.randomUUID(); @@ -63,10 +66,14 @@ void setUp() { service = new ApiExecutionService(requestRepository, connectorRepository, permissionResolver, encryptionService, authApplier, oauth2TokenService, executor, responseMasker, maskingResolutionService, stateService, - eventPublisher, JsonMapper.builder().build(), - new ApigovRequestProperties(5_242_880L, 8_000_000L, 65_536L)); + eventPublisher, JsonMapper.builder().build(), variableResolutionService, + new ApigovRequestProperties(5_242_880L, 8_000_000L, 65_536L, 8192)); lenient().when(maskingResolutionService.resolveApplicable(any(), any(), any())) .thenReturn(java.util.List.of()); + // No connector variables configured: the resolver is a no-op and the composed call is + // handed to the executor unchanged. + lenient().when(variableResolutionService.resolve(any(), any(), any(), any())) + .thenReturn(com.bablsoft.accessflow.apigov.api.ResolvedApiVariables.empty()); } private ApiRequestEntity approved() { @@ -97,7 +104,7 @@ void executeSuccessMasksResponseAndRecordsExecuted() { when(connectorRepository.findById(connectorId)).thenReturn(Optional.of(connector())); when(authApplier.authHeaders(any(), any(), anyInt())).thenReturn(java.util.Map.of()); when(executor.execute(any(ApiCallRequest.class))).thenReturn(new ApiCallResult(200, 12, 42, false, "{\"ssn\":\"x\"}", "application/json")); - var perm = new ResolvedApiConnectorPermission(connectorId, userId, true, false, false, + var perm = new ResolvedApiConnectorPermission(connectorId, userId, true, false, false, false, java.util.List.of(), java.util.List.of("ssn"), null); when(permissionResolver.resolve(connectorId, userId)).thenReturn(Optional.of(perm)); when(responseMasker.mask(any(), any(), any())).thenReturn("{\"ssn\":\"***\"}"); @@ -326,4 +333,132 @@ void executeInlineReturnsFailureWhenConnectorMissing() { ApiBodyType.RAW, null, null, null, null))) .isInstanceOf(com.bablsoft.accessflow.apigov.api.ApiExecutionException.class); } + + // --- AF-613: dynamic variables ------------------------------------------------------------ + + @Test + void resolvesVariablesAgainstTheFullyBuiltHeaderSetIncludingAuth() { + var entity = approved(); + var c = connector(); + c.setAuthMethod(ApiAuthMethod.API_KEY); + when(stateService.require(requestId)).thenReturn(entity); + when(connectorRepository.findById(connectorId)).thenReturn(Optional.of(c)); + when(authApplier.authHeaders(any(), any(), anyInt())) + .thenReturn(java.util.Map.of("Authorization", "Basic token_value")); + lenient().when(permissionResolver.resolve(connectorId, userId)).thenReturn(Optional.empty()); + when(executor.execute(any(ApiCallRequest.class))) + .thenReturn(new ApiCallResult(200, 5, 2, false, "{}", "application/json")); + + service.execute(requestId); + + // The context must carry the resolved auth header, or a vendor scheme that signs it cannot work. + var context = org.mockito.ArgumentCaptor.forClass( + com.bablsoft.accessflow.apigov.api.ApiVariableRequestContext.class); + verify(variableResolutionService).resolve(any(), org.mockito.ArgumentMatchers.eq(connectorId), + context.capture(), any()); + assertThat(context.getValue().headers()).containsEntry("Authorization", "Basic token_value"); + assertThat(context.getValue().method()).isEqualTo("GET"); + assertThat(context.getValue().path()).isEqualTo("/data"); + } + + @Test + void substitutesResolvedVariablesIntoTheOutboundCall() { + var entity = approved(); + entity.setRequestPath("/data/{{tenant}}"); + when(stateService.require(requestId)).thenReturn(entity); + when(connectorRepository.findById(connectorId)).thenReturn(Optional.of(connector())); + lenient().when(authApplier.authHeaders(any(), any(), anyInt())).thenReturn(java.util.Map.of()); + lenient().when(permissionResolver.resolve(connectorId, userId)).thenReturn(Optional.empty()); + when(variableResolutionService.resolve(any(), any(), any(), any())).thenReturn( + new com.bablsoft.accessflow.apigov.api.ResolvedApiVariables( + java.util.Map.of("tenant", "acme"), java.util.List.of())); + when(executor.execute(any(ApiCallRequest.class))) + .thenReturn(new ApiCallResult(200, 5, 2, false, "{}", "application/json")); + + service.execute(requestId); + + var sent = org.mockito.ArgumentCaptor.forClass(ApiCallRequest.class); + verify(executor).execute(sent.capture()); + assertThat(sent.getValue().path()).isEqualTo("/data/acme"); + } + + @Test + void passesThePersistedOverridesToTheResolver() { + var entity = approved(); + entity.setVariableOverrides("{\"nonce\":\"fixed\"}"); + when(stateService.require(requestId)).thenReturn(entity); + when(connectorRepository.findById(connectorId)).thenReturn(Optional.of(connector())); + lenient().when(authApplier.authHeaders(any(), any(), anyInt())).thenReturn(java.util.Map.of()); + lenient().when(permissionResolver.resolve(connectorId, userId)).thenReturn(Optional.empty()); + when(executor.execute(any(ApiCallRequest.class))) + .thenReturn(new ApiCallResult(200, 5, 2, false, "{}", "application/json")); + + service.execute(requestId); + + verify(variableResolutionService).resolve(any(), any(), any(), + org.mockito.ArgumentMatchers.eq(java.util.Map.of("nonce", "fixed"))); + } + + /** + * The 401 retry rebuilds headers with the fresh bearer, so variables must be resolved again: a + * signature computed over the stale token would simply fail a second time, and a nonce must not + * be replayed. + */ + @Test + void oauth2RetryReResolvesVariablesAgainstTheRefreshedToken() { + var entity = approved(); + var c = connector(); + c.setAuthMethod(ApiAuthMethod.OAUTH2_CLIENT_CREDENTIALS); + when(stateService.require(requestId)).thenReturn(entity); + when(connectorRepository.findById(connectorId)).thenReturn(Optional.of(c)); + when(oauth2TokenService.accessToken(c)).thenReturn("stale"); + when(oauth2TokenService.fetchFresh(c)).thenReturn("fresh"); + when(executor.execute(any(ApiCallRequest.class))) + .thenReturn(new ApiCallResult(401, 5, 0, false, "", null)) + .thenReturn(new ApiCallResult(200, 5, 2, false, "{}", "application/json")); + + service.execute(requestId); + + var contexts = org.mockito.ArgumentCaptor.forClass( + com.bablsoft.accessflow.apigov.api.ApiVariableRequestContext.class); + verify(variableResolutionService, org.mockito.Mockito.times(2)) + .resolve(any(), any(), contexts.capture(), any()); + assertThat(contexts.getAllValues().get(0).headers()).containsEntry("Authorization", "Bearer stale"); + assertThat(contexts.getAllValues().get(1).headers()).containsEntry("Authorization", "Bearer fresh"); + } + + /** + * api_requests.error_message is persisted and shown to reviewers, and the JDK's IOException + * message can embed the substituted URI. Every resolved value must be scrubbed before it escapes. + */ + @Test + void redactsResolvedValuesFromAPersistedFailureMessage() { + var entity = approved(); + when(stateService.require(requestId)).thenReturn(entity); + when(connectorRepository.findById(connectorId)).thenReturn(Optional.of(connector())); + lenient().when(authApplier.authHeaders(any(), any(), anyInt())).thenReturn(java.util.Map.of()); + lenient().when(permissionResolver.resolve(connectorId, userId)).thenReturn(Optional.empty()); + when(variableResolutionService.resolve(any(), any(), any(), any())).thenReturn( + new com.bablsoft.accessflow.apigov.api.ResolvedApiVariables( + java.util.Map.of("sig", "s3cr3tdigest"), java.util.List.of())); + when(executor.execute(any(ApiCallRequest.class))).thenThrow(new ApiExecutionException( + "API call failed: connect to https://api.test/data?sig=s3cr3tdigest")); + + var result = service.execute(requestId); + + assertThat(result.getErrorMessage()).doesNotContain("s3cr3tdigest").contains("***"); + verify(stateService).apply(entity, QueryStatus.FAILED); + } + + @Test + void redactLeavesMessagesWithoutResolvedValuesUntouched() { + var resolved = new com.bablsoft.accessflow.apigov.api.ResolvedApiVariables( + java.util.Map.of("a", "abc", "blank", ""), java.util.List.of()); + + assertThat(ApiExecutionService.redact("plain failure", resolved)).isEqualTo("plain failure"); + assertThat(ApiExecutionService.redact(null, resolved)).isNull(); + assertThat(ApiExecutionService.redact("x", null)).isEqualTo("x"); + // A blank resolved value must not turn every character into ***. + assertThat(ApiExecutionService.redact("hi", resolved)).isEqualTo("hi"); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiRequestVariableSubstitutionTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiRequestVariableSubstitutionTest.java new file mode 100644 index 00000000..14a58f69 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiRequestVariableSubstitutionTest.java @@ -0,0 +1,236 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiBodyType; +import com.bablsoft.accessflow.apigov.api.ApiFormField; +import com.bablsoft.accessflow.apigov.api.ApiProtocol; +import com.bablsoft.accessflow.apigov.api.ApiVariableTargetType; +import com.bablsoft.accessflow.apigov.api.ResolvedApiVariables; +import com.bablsoft.accessflow.apigov.internal.client.ApiCallRequest; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiRequestVariableSubstitutionTest { + + private static ApiCallRequest request(Map headers, Map query, + ApiBodyType bodyType, String body, + List formFields) { + return new ApiCallRequest(ApiProtocol.REST, "https://api.example.com", "POST", "/v1/pay", + headers, query, bodyType, body, "application/json", formFields, null, 30_000, + 1_000_000L, "createPayment"); + } + + private static ResolvedApiVariables resolved(Map values) { + return new ResolvedApiVariables(values, List.of()); + } + + @Nested + class CanonicalQuery { + + @Test + void isEmptyForNoParameters() { + assertThat(ApiRequestVariableSubstitution.canonicalQuery(Map.of())).isEmpty(); + assertThat(ApiRequestVariableSubstitution.canonicalQuery(null)).isEmpty(); + } + + /** Sorted rather than wire order, so a signature over it reproduces on both sides. */ + @Test + void sortsKeysRegardlessOfInsertionOrder() { + var params = new LinkedHashMap(); + params.put("z", "1"); + params.put("a", "2"); + + assertThat(ApiRequestVariableSubstitution.canonicalQuery(params)).isEqualTo("a=2&z=1"); + } + + @Test + void percentEncodesKeysAndValues() { + assertThat(ApiRequestVariableSubstitution.canonicalQuery(Map.of("a b", "c&d"))) + .isEqualTo("a+b=c%26d"); + } + + @Test + void rendersANullValueAsEmpty() { + var params = new LinkedHashMap(); + params.put("a", null); + + assertThat(ApiRequestVariableSubstitution.canonicalQuery(params)).isEqualTo("a="); + } + } + + @Nested + class BodyForContext { + + @Test + void rawBodyIsUsedVerbatim() { + var req = request(Map.of(), Map.of(), ApiBodyType.RAW, "{\"a\":1}", List.of()); + + assertThat(ApiRequestVariableSubstitution.bodyForContext(req)).isEqualTo("{\"a\":1}"); + } + + @Test + void noBodyIsEmpty() { + var req = request(Map.of(), Map.of(), ApiBodyType.NONE, null, List.of()); + + assertThat(ApiRequestVariableSubstitution.bodyForContext(req)).isEmpty(); + } + + @Test + void formUrlencodedIsCanonicalized() { + var fields = List.of( + new ApiFormField("z", ApiFormField.ApiFormFieldType.TEXT, "1", null, null), + new ApiFormField("a", ApiFormField.ApiFormFieldType.TEXT, "2", null, null)); + var req = request(Map.of(), Map.of(), ApiBodyType.FORM_URLENCODED, null, fields); + + assertThat(ApiRequestVariableSubstitution.bodyForContext(req)).isEqualTo("a=2&z=1"); + } + + /** Multipart boundaries are generated per send, so no signature over one is reproducible. */ + @Test + void formDataIsEmptyBecauseItIsNotReproducible() { + var fields = List.of(new ApiFormField("a", ApiFormField.ApiFormFieldType.TEXT, "1", null, null)); + var req = request(Map.of(), Map.of(), ApiBodyType.FORM_DATA, null, fields); + + assertThat(ApiRequestVariableSubstitution.bodyForContext(req)).isEmpty(); + } + } + + @Nested + class Substitution { + + @Test + void returnsTheRequestUnchangedWhenNothingResolved() { + var req = request(Map.of("X", "{{v}}"), Map.of(), ApiBodyType.RAW, "{{v}}", List.of()); + + assertThat(ApiRequestVariableSubstitution.apply(req, ResolvedApiVariables.empty())) + .isSameAs(req); + assertThat(ApiRequestVariableSubstitution.apply(req, null)).isSameAs(req); + } + + @Test + void substitutesHeaderValuesPathAndQueryValues() { + var req = request(Map.of("X-Signature", "{{sig}}"), Map.of("nonce", "{{sig}}"), + ApiBodyType.RAW, null, List.of()); + + var out = ApiRequestVariableSubstitution.apply(req, resolved(Map.of("sig", "abc123"))); + + assertThat(out.headers()).containsEntry("X-Signature", "abc123"); + assertThat(out.queryParams()).containsEntry("nonce", "abc123"); + } + + @Test + void substitutesThePath() { + var req = new ApiCallRequest(ApiProtocol.REST, "https://x", "GET", "/v1/{{tenant}}/items", + Map.of(), Map.of(), ApiBodyType.NONE, null, null, List.of(), null, 1, 1L, null); + + assertThat(ApiRequestVariableSubstitution.apply(req, resolved(Map.of("tenant", "acme"))) + .path()).isEqualTo("/v1/acme/items"); + } + + @Test + void substitutesARawBody() { + var req = request(Map.of(), Map.of(), ApiBodyType.RAW, + "{\"data\":\"x\",\"HMAC\":\"{{signature}}\"}", List.of()); + + assertThat(ApiRequestVariableSubstitution.apply(req, resolved(Map.of("signature", "deadbeef"))) + .body()).isEqualTo("{\"data\":\"x\",\"HMAC\":\"deadbeef\"}"); + } + + /** Header names must stay fixed — a variable-named header could not be reviewed. */ + @Test + void doesNotSubstituteHeaderNamesOrQueryKeys() { + var req = request(Map.of("{{name}}", "v"), Map.of("{{name}}", "v"), + ApiBodyType.NONE, null, List.of()); + + var out = ApiRequestVariableSubstitution.apply(req, resolved(Map.of("name", "X-Evil"))); + + assertThat(out.headers()).containsKey("{{name}}").doesNotContainKey("X-Evil"); + assertThat(out.queryParams()).containsKey("{{name}}"); + } + + /** The connector's target host is admin config; a variable there would be an SSRF pivot. */ + @Test + void doesNotSubstituteTheBaseUrl() { + var req = new ApiCallRequest(ApiProtocol.REST, "https://{{host}}/api", "GET", "/x", + Map.of(), Map.of(), ApiBodyType.NONE, null, null, List.of(), null, 1, 1L, null); + + assertThat(ApiRequestVariableSubstitution.apply(req, resolved(Map.of("host", "evil.test"))) + .baseUrl()).isEqualTo("https://{{host}}/api"); + } + + @Test + void doesNotSubstituteABinaryBodyBecauseItIsBase64() { + var req = request(Map.of(), Map.of(), ApiBodyType.BINARY, "e3t2fX0=", List.of()); + + assertThat(ApiRequestVariableSubstitution.apply(req, resolved(Map.of("v", "x"))).body()) + .isEqualTo("e3t2fX0="); + } + + @Test + void substitutesTextFormPartsButNotFileParts() { + var fields = List.of( + new ApiFormField("sig", ApiFormField.ApiFormFieldType.TEXT, "{{v}}", null, null), + new ApiFormField("doc", ApiFormField.ApiFormFieldType.FILE, "{{v}}", "a.pdf", + "application/pdf")); + var req = request(Map.of(), Map.of(), ApiBodyType.FORM_DATA, null, fields); + + var out = ApiRequestVariableSubstitution.apply(req, resolved(Map.of("v", "abc"))); + + assertThat(out.formFields().get(0).value()).isEqualTo("abc"); + assertThat(out.formFields().get(1).value()).isEqualTo("{{v}}"); + } + + @Test + void leavesUnknownBarePlaceholdersLiteral() { + var req = request(Map.of(), Map.of(), ApiBodyType.RAW, "{{unrelated}}", List.of()); + + assertThat(ApiRequestVariableSubstitution.apply(req, resolved(Map.of("v", "x"))).body()) + .isEqualTo("{{unrelated}}"); + } + } + + @Nested + class Injections { + + @Test + void appliesAHeaderTargetAfterSubstitution() { + var req = request(Map.of("X-Sig", "placeholder"), Map.of(), ApiBodyType.NONE, null, List.of()); + var resolved = new ResolvedApiVariables(Map.of("sig", "abc"), + List.of(new ResolvedApiVariables.ApiVariableInjection( + ApiVariableTargetType.HEADER, "X-Sig", "abc"))); + + assertThat(ApiRequestVariableSubstitution.apply(req, resolved).headers()) + .containsEntry("X-Sig", "abc"); + } + + @Test + void appliesAQueryTarget() { + var req = request(Map.of(), Map.of(), ApiBodyType.NONE, null, List.of()); + var resolved = new ResolvedApiVariables(Map.of("ts", "12345"), + List.of(new ResolvedApiVariables.ApiVariableInjection( + ApiVariableTargetType.QUERY, "timestamp", "12345"))); + + assertThat(ApiRequestVariableSubstitution.apply(req, resolved).queryParams()) + .containsEntry("timestamp", "12345"); + } + + /** A placeholder in the body and a header target on the same variable must both apply. */ + @Test + void aPlaceholderAndATargetCoexist() { + var req = request(Map.of(), Map.of(), ApiBodyType.RAW, "sig={{sig}}", List.of()); + var resolved = new ResolvedApiVariables(Map.of("sig", "abc"), + List.of(new ResolvedApiVariables.ApiVariableInjection( + ApiVariableTargetType.HEADER, "X-Sig", "abc"))); + + var out = ApiRequestVariableSubstitution.apply(req, resolved); + + assertThat(out.body()).isEqualTo("sig=abc"); + assertThat(out.headers()).containsEntry("X-Sig", "abc"); + } + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluatorTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluatorTest.java new file mode 100644 index 00000000..2b9f963d --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableEvaluatorTest.java @@ -0,0 +1,214 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApiVariableEvaluatorTest { + + private static final Instant NOW = Instant.parse("2026-07-20T10:30:45.123Z"); + + private final ApiVariableEvaluator evaluator = + new ApiVariableEvaluator(Clock.fixed(NOW, ZoneOffset.UTC)); + + private String eval(ApiVariableKind kind, ApiVariableAlgorithm algorithm, + ApiVariableEncoding encoding, String input, String secret) { + return evaluator.evaluate("v", kind, algorithm, encoding, input, secret); + } + + @Nested + class Constant { + + @Test + void returnsInputVerbatim() { + assertThat(eval(ApiVariableKind.CONSTANT, null, null, "v1.2", null)).isEqualTo("v1.2"); + } + + @Test + void treatsNullInputAsEmpty() { + assertThat(eval(ApiVariableKind.CONSTANT, null, null, null, null)).isEmpty(); + } + } + + @Nested + class Nonces { + + @Test + void uuidProducesAParseableAndDistinctValueEachCall() { + var first = eval(ApiVariableKind.UUID, null, null, null, null); + var second = eval(ApiVariableKind.UUID, null, null, null, null); + + assertThat(java.util.UUID.fromString(first)).isNotNull(); + assertThat(first).isNotEqualTo(second); + } + + @Test + void randomHexDefaultsToSixteenBytes() { + assertThat(eval(ApiVariableKind.RANDOM_HEX, null, null, null, null)).hasSize(32); + } + + @Test + void randomHexHonoursAnExplicitByteCount() { + assertThat(eval(ApiVariableKind.RANDOM_HEX, null, null, "8", null)).hasSize(16); + } + + @Test + void randomHexHonoursTheEncoding() { + var value = eval(ApiVariableKind.RANDOM_HEX, null, ApiVariableEncoding.BASE64URL, "16", null); + + assertThat(value).doesNotContain("=").doesNotContain("+").doesNotContain("/"); + } + + @Test + void randomHexDiffersBetweenCalls() { + assertThat(eval(ApiVariableKind.RANDOM_HEX, null, null, "16", null)) + .isNotEqualTo(eval(ApiVariableKind.RANDOM_HEX, null, null, "16", null)); + } + + @Test + void randomHexRejectsANonNumericOrOutOfRangeSize() { + assertThatThrownBy(() -> eval(ApiVariableKind.RANDOM_HEX, null, null, "abc", null)) + .isInstanceOf(ApiVariableEvaluationException.class); + assertThatThrownBy(() -> eval(ApiVariableKind.RANDOM_HEX, null, null, "0", null)) + .isInstanceOf(ApiVariableEvaluationException.class); + assertThatThrownBy(() -> eval(ApiVariableKind.RANDOM_HEX, null, null, "257", null)) + .isInstanceOf(ApiVariableEvaluationException.class); + } + } + + @Nested + class Timestamps { + + @Test + void timestampDefaultsToIsoTruncatedToSeconds() { + assertThat(eval(ApiVariableKind.TIMESTAMP, null, null, null, null)) + .isEqualTo("2026-07-20T10:30:45Z"); + } + + @Test + void timestampAppliesAnExplicitPatternAtUtc() { + assertThat(eval(ApiVariableKind.TIMESTAMP, null, null, "yyyyMMdd'T'HHmmss'Z'", null)) + .isEqualTo("20260720T103045Z"); + } + + @Test + void timestampRejectsAnInvalidPattern() { + assertThatThrownBy(() -> eval(ApiVariableKind.TIMESTAMP, null, null, "yyyy-QQQQQQQ", null)) + .isInstanceOf(ApiVariableEvaluationException.class); + } + + @Test + void epochMillisUsesTheClock() { + assertThat(eval(ApiVariableKind.EPOCH_MILLIS, null, null, null, null)) + .isEqualTo(Long.toString(NOW.toEpochMilli())); + } + } + + @Nested + class Hashes { + + // SHA-256("abc"), the NIST test vector. + private static final String SHA256_ABC = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + @Test + void sha256MatchesTheKnownVector() { + assertThat(eval(ApiVariableKind.HASH, ApiVariableAlgorithm.SHA256, null, "abc", null)) + .isEqualTo(SHA256_ABC); + } + + @Test + void md5MatchesTheKnownVector() { + assertThat(eval(ApiVariableKind.HASH, ApiVariableAlgorithm.MD5, null, "abc", null)) + .isEqualTo("900150983cd24fb0d6963f7d28e17f72"); + } + + @Test + void hashHonoursBase64Encoding() { + var expected = java.util.Base64.getEncoder() + .encodeToString(java.util.HexFormat.of().parseHex(SHA256_ABC)); + + assertThat(eval(ApiVariableKind.HASH, ApiVariableAlgorithm.SHA256, + ApiVariableEncoding.BASE64, "abc", null)).isEqualTo(expected); + } + + @Test + void hashRejectsAnHmacAlgorithm() { + assertThatThrownBy(() -> eval(ApiVariableKind.HASH, ApiVariableAlgorithm.HMAC_SHA256, + null, "abc", null)).isInstanceOf(ApiVariableEvaluationException.class); + } + } + + @Nested + class Macs { + + @Test + void hmacSha256MatchesRfc4231TestCase2() { + // key = "Jefe", data = "what do ya want for nothing?" + assertThat(eval(ApiVariableKind.HMAC, ApiVariableAlgorithm.HMAC_SHA256, null, + "what do ya want for nothing?", "Jefe")) + .isEqualTo("5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"); + } + + @Test + void hmacSha512MatchesRfc4231TestCase2() { + assertThat(eval(ApiVariableKind.HMAC, ApiVariableAlgorithm.HMAC_SHA512, null, + "what do ya want for nothing?", "Jefe")) + .isEqualTo("164b7a7bfcf819e2e395fbe73b56e0a387bd64222e831fd610270cd7ea250554" + + "9758bf75c05a994a6d034f65f8f0e6fdcaeab1a34d4a6b4b636e070a38bce737"); + } + + @Test + void hmacRejectsAMissingSecret() { + assertThatThrownBy(() -> eval(ApiVariableKind.HMAC, ApiVariableAlgorithm.HMAC_SHA256, + null, "data", null)).isInstanceOf(ApiVariableEvaluationException.class); + assertThatThrownBy(() -> eval(ApiVariableKind.HMAC, ApiVariableAlgorithm.HMAC_SHA256, + null, "data", "")).isInstanceOf(ApiVariableEvaluationException.class); + } + + @Test + void hmacRejectsADigestAlgorithm() { + assertThatThrownBy(() -> eval(ApiVariableKind.HMAC, ApiVariableAlgorithm.SHA256, + null, "data", "k")).isInstanceOf(ApiVariableEvaluationException.class); + } + + @Test + void hmacRejectsAMissingAlgorithm() { + assertThatThrownBy(() -> eval(ApiVariableKind.HMAC, null, null, "data", "k")) + .isInstanceOf(ApiVariableEvaluationException.class); + } + } + + @Nested + class Encodings { + + @Test + void encodeProducesPaddedStandardBase64() { + assertThat(eval(ApiVariableKind.ENCODE, null, ApiVariableEncoding.BASE64, "user:pw", null)) + .isEqualTo("dXNlcjpwdw=="); + } + + /** RFC 7515 shape: URL-safe and unpadded, which is what vendors actually specify. */ + @Test + void base64UrlIsUnpaddedAndUrlSafe() { + var value = eval(ApiVariableKind.ENCODE, null, ApiVariableEncoding.BASE64URL, "user:pw", null); + + assertThat(value).isEqualTo("dXNlcjpwdw"); + } + + @Test + void hexIsLowercase() { + assertThat(eval(ApiVariableKind.ENCODE, null, ApiVariableEncoding.HEX, "ÿ", null)) + .isEqualTo("c3bf"); + } + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableGraphTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableGraphTest.java new file mode 100644 index 00000000..bcecc59d --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableGraphTest.java @@ -0,0 +1,127 @@ +package com.bablsoft.accessflow.apigov.internal; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApiVariableGraphTest { + + private static ApiVariableGraph.Node node(String name, String expression) { + return new ApiVariableGraph.Node(name, expression); + } + + private static List orderOf(List nodes) { + return ApiVariableGraph.evaluationOrder(nodes).stream().map(ApiVariableGraph.Node::name).toList(); + } + + @Test + void returnsEmptyForNoNodes() { + assertThat(ApiVariableGraph.evaluationOrder(List.of())).isEmpty(); + } + + @Test + void placesDependenciesBeforeDependents() { + var order = orderOf(List.of( + node("signature", "{{nonce}}{{timestamp}}"), + node("nonce", null), + node("timestamp", null))); + + assertThat(order).containsExactly("nonce", "timestamp", "signature"); + } + + @Test + void resolvesTransitiveChains() { + var order = orderOf(List.of( + node("c", "{{b}}"), + node("b", "{{a}}"), + node("a", null))); + + assertThat(order).containsExactly("a", "b", "c"); + } + + @Test + void followsQualifiedReferencesToo() { + var order = orderOf(List.of(node("sig", "{{var.key}}"), node("key", null))); + + assertThat(order).containsExactly("key", "sig"); + } + + /** + * Independent variables must evaluate in the caller's order, not hash order. This is observable: + * two TIMESTAMP variables resolved in a different sequence can produce different values, and a + * signature computed over them would then differ between nodes. + */ + @Test + void preservesInputOrderAmongIndependentNodes() { + assertThat(orderOf(List.of(node("z", null), node("a", null), node("m", null)))) + .containsExactly("z", "a", "m"); + } + + @Test + void preservesInputOrderAmongIndependentDependents() { + var order = orderOf(List.of( + node("base", null), + node("second", "{{base}}"), + node("first", "{{base}}"))); + + assertThat(order).containsExactly("base", "second", "first"); + } + + @Test + void ignoresBareReferencesToNamesThatDoNotExist() { + // An unknown bare reference stays literal at render time, so it is not a dependency edge. + assertThat(orderOf(List.of(node("a", "{{handlebarsToken}}")))).containsExactly("a"); + } + + @Test + void rejectsQualifiedReferenceToAnUnknownName() { + assertThatThrownBy(() -> ApiVariableGraph.evaluationOrder(List.of(node("a", "{{var.missing}}")))) + .isInstanceOf(ApiVariableGraph.UnknownReferenceException.class) + .satisfies(ex -> { + var e = (ApiVariableGraph.UnknownReferenceException) ex; + assertThat(e.from()).isEqualTo("a"); + assertThat(e.missing()).isEqualTo("missing"); + }); + } + + @Test + void rejectsSelfReference() { + assertThatThrownBy(() -> ApiVariableGraph.evaluationOrder(List.of(node("a", "{{a}}")))) + .isInstanceOf(ApiVariableGraph.CycleException.class) + .satisfies(ex -> assertThat(((ApiVariableGraph.CycleException) ex).names()) + .containsExactly("a")); + } + + @Test + void rejectsTwoNodeCycle() { + assertThatThrownBy(() -> ApiVariableGraph.evaluationOrder(List.of( + node("a", "{{b}}"), node("b", "{{a}}")))) + .isInstanceOf(ApiVariableGraph.CycleException.class) + .satisfies(ex -> assertThat(((ApiVariableGraph.CycleException) ex).names()) + .containsExactlyInAnyOrder("a", "b")); + } + + @Test + void rejectsThreeNodeCycleAndNamesOnlyTheParticipants() { + assertThatThrownBy(() -> ApiVariableGraph.evaluationOrder(List.of( + node("standalone", null), + node("a", "{{b}}"), node("b", "{{c}}"), node("c", "{{a}}")))) + .isInstanceOf(ApiVariableGraph.CycleException.class) + .satisfies(ex -> assertThat(((ApiVariableGraph.CycleException) ex).names()) + .containsExactlyInAnyOrder("a", "b", "c")); + } + + @Test + void handlesADiamond() { + var order = orderOf(List.of( + node("top", "{{left}}{{right}}"), + node("left", "{{base}}"), + node("right", "{{base}}"), + node("base", null))); + + assertThat(order).containsExactly("base", "left", "right", "top"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTargetsTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTargetsTest.java new file mode 100644 index 00000000..9603496a --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTargetsTest.java @@ -0,0 +1,75 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiVariableTargetType; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ApiVariableTargetsTest { + + @Test + void parsesAHeaderTarget() { + var target = ApiVariableTargets.parse("header:X-Signature"); + + assertThat(target).isNotNull(); + assertThat(target.type()).isEqualTo(ApiVariableTargetType.HEADER); + assertThat(target.key()).isEqualTo("X-Signature"); + } + + @Test + void parsesAQueryTarget() { + var target = ApiVariableTargets.parse("query:signature"); + + assertThat(target.type()).isEqualTo(ApiVariableTargetType.QUERY); + assertThat(target.key()).isEqualTo("signature"); + } + + @Test + void trimsSurroundingWhitespace() { + assertThat(ApiVariableTargets.parse(" header:X-Sig ").key()).isEqualTo("X-Sig"); + } + + @Test + void treatsBlankAsNoTarget() { + assertThat(ApiVariableTargets.parse(null)).isNull(); + assertThat(ApiVariableTargets.parse(" ")).isNull(); + } + + @Test + void returnsNullForAMalformedTarget() { + assertThat(ApiVariableTargets.parse("body")).isNull(); + assertThat(ApiVariableTargets.parse("cookie:x")).isNull(); + assertThat(ApiVariableTargets.parse("header:")).isNull(); + } + + /** + * The key charset is RFC 7230's {@code token}, so a target can never smuggle a separator or a + * control character into the header block. + */ + @Test + void rejectsHeaderNamesCarryingSeparatorsOrControlCharacters() { + assertThat(ApiVariableTargets.isValid("header:X-Sig: evil")).isFalse(); + assertThat(ApiVariableTargets.isValid("header:X-Sig\r\nEvil")).isFalse(); + assertThat(ApiVariableTargets.isValid("header:X Sig")).isFalse(); + assertThat(ApiVariableTargets.isValid("header:" + "x".repeat(129))).isFalse(); + } + + @Test + void treatsBlankAsValidBecauseNoTargetIsTheCommonCase() { + assertThat(ApiVariableTargets.isValid(null)).isTrue(); + assertThat(ApiVariableTargets.isValid("")).isTrue(); + assertThat(ApiVariableTargets.isValid("header:X-Sig")).isTrue(); + } + + @Test + void normalizesBlankToNullAndTrimsOtherwise() { + assertThat(ApiVariableTargets.normalize(" ")).isNull(); + assertThat(ApiVariableTargets.normalize(null)).isNull(); + assertThat(ApiVariableTargets.normalize(" header:X-Sig ")).isEqualTo("header:X-Sig"); + } + + @Test + void isCaseInsensitiveOnTheTypePrefix() { + assertThat(ApiVariableTargets.parse("header:X").type()).isEqualTo(ApiVariableTargetType.HEADER); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTemplateTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTemplateTest.java new file mode 100644 index 00000000..847c36ef --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/ApiVariableTemplateTest.java @@ -0,0 +1,167 @@ +package com.bablsoft.accessflow.apigov.internal; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ApiVariableTemplateTest { + + /** Resolves variable references from a fixed map; request references are unavailable. */ + private static String renderVars(String template, Map values, boolean strict) { + return ApiVariableTemplate.render(template, + ref -> ref.isVariable() ? values.get(ref.key()) : null, + strict ? ApiVariableTemplate.EXPRESSION_STRICT_SCOPES + : ApiVariableTemplate.SUBSTITUTION_STRICT_SCOPES); + } + + @Nested + class Grammar { + + @Test + void substitutesBareReference() { + assertThat(renderVars("Bearer {{token}}", Map.of("token", "abc"), false)) + .isEqualTo("Bearer abc"); + } + + @Test + void substitutesQualifiedReference() { + assertThat(renderVars("{{var.token}}", Map.of("token", "abc"), false)).isEqualTo("abc"); + } + + @Test + void toleratesInnerWhitespace() { + assertThat(renderVars("{{ token }}", Map.of("token", "abc"), false)).isEqualTo("abc"); + } + + @Test + void substitutesEveryOccurrence() { + assertThat(renderVars("{{a}}-{{b}}-{{a}}", Map.of("a", "1", "b", "2"), false)) + .isEqualTo("1-2-1"); + } + + @Test + void returnsNullAndEmptyUnchanged() { + assertThat(renderVars(null, Map.of(), true)).isNull(); + assertThat(renderVars("", Map.of(), true)).isEmpty(); + } + + @Test + void leavesTextWithoutPlaceholdersUntouched() { + assertThat(renderVars("{ \"a\": 1 }", Map.of("a", "x"), true)).isEqualTo("{ \"a\": 1 }"); + } + + @Test + void treatsValueAsLiteralReplacementNotRegex() { + // A digest is hex, but a CONSTANT can hold anything — $1 and \n must not be interpreted. + assertThat(renderVars("[{{v}}]", Map.of("v", "$1\\n"), false)).isEqualTo("[$1\\n]"); + } + } + + @Nested + class Strictness { + + @Test + void leavesUnknownBareReferenceLiteral() { + // Bodies legitimately carry braces from unrelated templating; erroring would break them. + assertThat(renderVars("{{handlebars}}", Map.of(), true)).isEqualTo("{{handlebars}}"); + } + + @Test + void throwsOnUnknownQualifiedReferenceWhenStrict() { + assertThatThrownBy(() -> renderVars("{{var.typo}}", Map.of(), true)) + .isInstanceOf(ApiVariableTemplate.UnresolvedReferenceException.class) + .satisfies(ex -> assertThat( + ((ApiVariableTemplate.UnresolvedReferenceException) ex).reference().key()) + .isEqualTo("typo")); + } + + @Test + void throwsOnUnresolvedRequestReferenceInExpressionScope() { + assertThatThrownBy(() -> ApiVariableTemplate.render("{{request.nope}}", ref -> null, + ApiVariableTemplate.EXPRESSION_STRICT_SCOPES)) + .isInstanceOf(ApiVariableTemplate.UnresolvedReferenceException.class); + } + + @Test + void leavesRequestReferenceLiteralAtSubstitutionSite() { + // At a substitution site there is no expression being evaluated, so request.* is + // meaningless rather than wrong — a body may contain such text for its own reasons. + assertThat(ApiVariableTemplate.render("{{request.body}}", ref -> null, + ApiVariableTemplate.SUBSTITUTION_STRICT_SCOPES)).isEqualTo("{{request.body}}"); + } + + @Test + void leavesForeignNamespaceLiteralEvenWhenStrict() { + assertThat(renderVars("{{aws.region}}", Map.of(), true)).isEqualTo("{{aws.region}}"); + } + } + + @Nested + class SecurityProperties { + + /** + * The containment property that makes per-request overrides safe. A submitter may override + * an overridable variable with arbitrary text; if that text were re-scanned, an override of + * "{{signingKey}}" would expand into the secret-derived value of another variable. Rendering + * is single-pass, so it stays literal. + */ + @Test + void doesNotRecursivelyExpandSubstitutedValues() { + var values = Map.of("userSupplied", "{{signingKey}}", "signingKey", "s3cr3t"); + + assertThat(renderVars("{{userSupplied}}", values, false)).isEqualTo("{{signingKey}}"); + } + + @Test + void doesNotExpandQualifiedReferenceHiddenInsideAValue() { + var values = Map.of("userSupplied", "{{var.signingKey}}", "signingKey", "s3cr3t"); + + assertThat(renderVars("{{userSupplied}}", values, true)).isEqualTo("{{var.signingKey}}"); + } + } + + @Nested + class ReferenceExtraction { + + @Test + void collectsBareAndQualifiedNamesInFirstAppearanceOrder() { + assertThat(ApiVariableTemplate.variableReferences("{{b}}{{var.a}}{{b}}")) + .containsExactly("b", "a"); + } + + @Test + void ignoresRequestAndForeignNamespaces() { + assertThat(ApiVariableTemplate.variableReferences("{{request.body}}{{aws.region}}")) + .isEmpty(); + } + + @Test + void strictReferencesCollectsOnlyQualifiedForm() { + assertThat(ApiVariableTemplate.strictVariableReferences("{{a}}{{var.b}}")) + .containsExactly("b"); + } + } + + @Nested + class NamePattern { + + @Test + void acceptsLetterLedAlphanumericNames() { + assertThat(ApiVariableTemplate.NAME_PATTERN.matcher("sig_1").matches()).isTrue(); + assertThat(ApiVariableTemplate.NAME_PATTERN.matcher("A").matches()).isTrue(); + } + + @Test + void rejectsNamesThatCouldShadowANamespaceOrBreakTheGrammar() { + assertThat(ApiVariableTemplate.NAME_PATTERN.matcher("request.body").matches()).isFalse(); + assertThat(ApiVariableTemplate.NAME_PATTERN.matcher("1sig").matches()).isFalse(); + assertThat(ApiVariableTemplate.NAME_PATTERN.matcher("").matches()).isFalse(); + assertThat(ApiVariableTemplate.NAME_PATTERN.matcher("a-b").matches()).isFalse(); + assertThat(ApiVariableTemplate.NAME_PATTERN.matcher("a".repeat(65)).matches()).isFalse(); + } + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorAdminServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorAdminServiceTest.java index d57ec5d0..97dd4782 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorAdminServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorAdminServiceTest.java @@ -163,7 +163,7 @@ void listForUserReturnsOnlyGrantedActiveNonExpiredConnectors() { @Test void getForUserReturnsWhenPermissionGrantsRead() { var entity = persistedConnector(); - var perm = new ResolvedApiConnectorPermission(entity.getId(), userId, true, false, false, + var perm = new ResolvedApiConnectorPermission(entity.getId(), userId, true, false, false, false, List.of(), List.of(), null); when(connectorRepository.findByIdAndOrganizationId(entity.getId(), orgId)).thenReturn(Optional.of(entity)); when(permissionResolver.resolve(entity.getId(), userId)).thenReturn(Optional.of(perm)); @@ -184,7 +184,7 @@ void getForUserThrowsWhenNoPermission() { @Test void getForUserThrowsWhenPermissionGrantsNeitherReadNorWrite() { var entity = persistedConnector(); - var perm = new ResolvedApiConnectorPermission(entity.getId(), userId, false, false, false, + var perm = new ResolvedApiConnectorPermission(entity.getId(), userId, false, false, false, false, List.of(), List.of(), null); when(connectorRepository.findByIdAndOrganizationId(entity.getId(), orgId)).thenReturn(Optional.of(entity)); when(permissionResolver.resolve(entity.getId(), userId)).thenReturn(Optional.of(perm)); @@ -452,7 +452,7 @@ void grantPermissionValidatesTargetUserInOrg() { when(permissionRepository.save(any())).thenAnswer(i -> i.getArgument(0)); var view = service.grantPermission(entity.getId(), orgId, UUID.randomUUID(), - new GrantApiConnectorPermissionCommand(userId, true, false, false, null, + new GrantApiConnectorPermissionCommand(userId, true, false, false, false, null, List.of("listPets"), List.of("data.ssn"))); assertThat(view.canRead()).isTrue(); @@ -468,7 +468,7 @@ void grantPermissionRejectsUserFromAnotherOrg() { when(userQueryService.findById(userId)).thenReturn(Optional.of(userView(userId, UUID.randomUUID()))); assertThatThrownBy(() -> service.grantPermission(entity.getId(), orgId, UUID.randomUUID(), - new GrantApiConnectorPermissionCommand(userId, true, false, false, null, null, null))) + new GrantApiConnectorPermissionCommand(userId, true, false, false, false, null, null, null))) .isInstanceOf(UserNotFoundException.class); } @@ -506,7 +506,7 @@ void updatePermissionMutatesFieldsAndPreservesProvenance() { when(permissionRepository.save(any())).thenAnswer(i -> i.getArgument(0)); var view = service.updatePermission(entity.getId(), orgId, permId, - new UpdateApiConnectorPermissionCommand(false, true, true, null, + new UpdateApiConnectorPermissionCommand(false, true, true, false, null, List.of("createPet"), List.of("data.token"))); assertThat(view.canRead()).isFalse(); @@ -530,7 +530,7 @@ void updatePermissionRejectsForeignPermission() { when(permissionRepository.findById(permId)).thenReturn(Optional.of(foreign)); assertThatThrownBy(() -> service.updatePermission(entity.getId(), orgId, permId, - new UpdateApiConnectorPermissionCommand(true, false, false, null, null, null))) + new UpdateApiConnectorPermissionCommand(true, false, false, false, null, null, null))) .isInstanceOf(ApiConnectorPermissionNotFoundException.class); } @@ -542,7 +542,7 @@ void updatePermissionRejectsMissingPermission() { when(permissionRepository.findById(permId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.updatePermission(entity.getId(), orgId, permId, - new UpdateApiConnectorPermissionCommand(true, false, false, null, null, null))) + new UpdateApiConnectorPermissionCommand(true, false, false, false, null, null, null))) .isInstanceOf(ApiConnectorPermissionNotFoundException.class); } @@ -552,7 +552,7 @@ void updatePermissionRejectsConnectorFromAnotherOrg() { when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)).thenReturn(Optional.empty()); assertThatThrownBy(() -> service.updatePermission(connectorId, orgId, UUID.randomUUID(), - new UpdateApiConnectorPermissionCommand(true, false, false, null, null, null))) + new UpdateApiConnectorPermissionCommand(true, false, false, false, null, null, null))) .isInstanceOf(ApiConnectorNotFoundException.class); } @@ -569,7 +569,7 @@ void grantGroupPermissionUpsertsAndReturnsView() { var view = service.grantGroupPermission(entity.getId(), orgId, UUID.randomUUID(), new com.bablsoft.accessflow.apigov.api.GrantApiConnectorGroupPermissionCommand( - groupId, true, false, false, null, List.of("listPets"), List.of("data.ssn"))); + groupId, true, false, false, false, null, List.of("listPets"), List.of("data.ssn"))); assertThat(view.groupId()).isEqualTo(groupId); assertThat(view.groupName()).isEqualTo("Analysts"); @@ -589,7 +589,7 @@ void grantGroupPermissionRejectsUnknownGroup() { assertThatThrownBy(() -> service.grantGroupPermission(entity.getId(), orgId, UUID.randomUUID(), new com.bablsoft.accessflow.apigov.api.GrantApiConnectorGroupPermissionCommand( - groupId, true, false, false, null, null, null))) + groupId, true, false, false, false, null, null, null))) .isInstanceOf(com.bablsoft.accessflow.core.api.UserGroupNotFoundException.class); } @@ -616,7 +616,7 @@ void updateGroupPermissionMutatesFieldsAndPreservesProvenance() { var view = service.updateGroupPermission(entity.getId(), orgId, permId, new com.bablsoft.accessflow.apigov.api.UpdateApiConnectorGroupPermissionCommand( - false, true, true, null, List.of("createPet"), List.of("data.token"))); + false, true, true, false, null, List.of("createPet"), List.of("data.token"))); assertThat(view.canRead()).isFalse(); assertThat(view.canWrite()).isTrue(); @@ -642,7 +642,7 @@ void updateGroupPermissionRejectsForeignPermission() { assertThatThrownBy(() -> service.updateGroupPermission(entity.getId(), orgId, permId, new com.bablsoft.accessflow.apigov.api.UpdateApiConnectorGroupPermissionCommand( - true, false, false, null, null, null))) + true, false, false, false, null, null, null))) .isInstanceOf(ApiConnectorPermissionNotFoundException.class); } @@ -655,7 +655,7 @@ void updateGroupPermissionRejectsMissingPermission() { assertThatThrownBy(() -> service.updateGroupPermission(entity.getId(), orgId, permId, new com.bablsoft.accessflow.apigov.api.UpdateApiConnectorGroupPermissionCommand( - true, false, false, null, null, null))) + true, false, false, false, null, null, null))) .isInstanceOf(ApiConnectorPermissionNotFoundException.class); } @@ -666,7 +666,7 @@ void updateGroupPermissionRejectsConnectorFromAnotherOrg() { assertThatThrownBy(() -> service.updateGroupPermission(connectorId, orgId, UUID.randomUUID(), new com.bablsoft.accessflow.apigov.api.UpdateApiConnectorGroupPermissionCommand( - true, false, false, null, null, null))) + true, false, false, false, null, null, null))) .isInstanceOf(ApiConnectorNotFoundException.class); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorPermissionLookupServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorPermissionLookupServiceTest.java index 98e3a2f0..627ea025 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorPermissionLookupServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorPermissionLookupServiceTest.java @@ -33,7 +33,7 @@ void mapsResolvedPermissionToView() { var userId = UUID.randomUUID(); var expiry = Instant.now(); when(permissionResolver.resolve(connectorId, userId)).thenReturn(Optional.of( - new ResolvedApiConnectorPermission(connectorId, userId, true, false, true, + new ResolvedApiConnectorPermission(connectorId, userId, true, false, true, false, List.of("listCharges"), List.of(), expiry))); var view = service.findFor(connectorId, userId).orElseThrow(); diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableAdminServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableAdminServiceTest.java new file mode 100644 index 00000000..99eefb89 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableAdminServiceTest.java @@ -0,0 +1,560 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiConnectorNotFoundException; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableNotFoundException; +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import com.bablsoft.accessflow.apigov.api.CreateApiConnectorVariableCommand; +import com.bablsoft.accessflow.apigov.api.IllegalApiConnectorVariableException; +import com.bablsoft.accessflow.apigov.api.ReorderApiConnectorVariablesCommand; +import com.bablsoft.accessflow.apigov.api.UpdateApiConnectorVariableCommand; +import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiConnectorEntity; +import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiConnectorVariableEntity; +import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorRepository; +import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorVariableRepository; +import com.bablsoft.accessflow.core.api.CredentialEncryptionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.support.StaticMessageSource; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DefaultApiConnectorVariableAdminServiceTest { + + @Mock private ApiConnectorVariableRepository variableRepository; + @Mock private ApiConnectorRepository connectorRepository; + @Mock private CredentialEncryptionService encryptionService; + + private DefaultApiConnectorVariableAdminService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID connectorId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + var messages = new StaticMessageSource(); + messages.setUseCodeAsDefaultMessage(true); + service = new DefaultApiConnectorVariableAdminService(variableRepository, connectorRepository, + encryptionService, messages); + + var connector = new ApiConnectorEntity(); + connector.setId(connectorId); + connector.setOrganizationId(orgId); + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector)); + when(encryptionService.encrypt(anyString())).thenAnswer(inv -> "enc:" + inv.getArgument(0)); + when(variableRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + existing(); + } + + private void existing(ApiConnectorVariableEntity... entities) { + when(variableRepository + .findAllByOrganizationIdAndConnectorIdOrderBySortOrderAscCreatedAtAscIdAsc(orgId, connectorId)) + .thenReturn(List.of(entities)); + } + + private ApiConnectorVariableEntity stored(String name, ApiVariableKind kind, String expression) { + var e = new ApiConnectorVariableEntity(); + e.setId(UUID.randomUUID()); + e.setOrganizationId(orgId); + e.setConnectorId(connectorId); + e.setName(name); + e.setKind(kind); + e.setExpression(expression); + when(variableRepository.findByIdAndOrganizationIdAndConnectorId(e.getId(), orgId, connectorId)) + .thenReturn(Optional.of(e)); + return e; + } + + private static CreateApiConnectorVariableCommand create(String name, ApiVariableKind kind, + String expression) { + return new CreateApiConnectorVariableCommand(name, kind, expression, null, null, null, null, + null, null, null); + } + + @Nested + class Scoping { + + @Test + void treatsAConnectorInAnotherOrganizationAsNotFound() { + when(connectorRepository.findByIdAndOrganizationId(any(), any())).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.listForConnector(connectorId, UUID.randomUUID())) + .isInstanceOf(ApiConnectorNotFoundException.class); + } + + @Test + void throwsWhenTheVariableIsNotOnThisConnector() { + when(variableRepository.findByIdAndOrganizationIdAndConnectorId(any(), any(), any())) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.delete(UUID.randomUUID(), connectorId, orgId)) + .isInstanceOf(ApiConnectorVariableNotFoundException.class); + } + } + + @Nested + class Naming { + + @Test + void rejectsABlankName() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create(" ", ApiVariableKind.CONSTANT, "x"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("name_required"); + } + + @Test + void rejectsANameThatCouldShadowANamespace() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("request.body", ApiVariableKind.CONSTANT, "x"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("name_invalid"); + } + + @Test + void rejectsADuplicateName() { + existing(stored("sig", ApiVariableKind.CONSTANT, "x")); + + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("sig", ApiVariableKind.CONSTANT, "y"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("name_duplicate"); + } + + @Test + void trimsTheName() { + var view = service.create(connectorId, orgId, create(" sig ", ApiVariableKind.CONSTANT, "x")); + + assertThat(view.name()).isEqualTo("sig"); + } + + @Test + void rejectsAMissingKind() { + assertThatThrownBy(() -> service.create(connectorId, orgId, create("v", null, "x"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("kind_required"); + } + } + + @Nested + class KindFieldRules { + + @Test + void requiresAnExpressionForConstant() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("v", ApiVariableKind.CONSTANT, null))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("expression_required"); + } + + @Test + void forbidsAnExpressionForUuid() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("v", ApiVariableKind.UUID, "anything"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("expression_forbidden"); + } + + @Test + void allowsABlankExpressionForTimestamp() { + assertThat(service.create(connectorId, orgId, create("ts", ApiVariableKind.TIMESTAMP, null)) + .kind()).isEqualTo(ApiVariableKind.TIMESTAMP); + } + + @Test + void requiresADigestAlgorithmForHash() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("h", ApiVariableKind.HASH, "x"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("algorithm_invalid"); + } + + /** An HMAC algorithm on a HASH — or a digest on an HMAC — is always a configuration error. */ + @Test + void rejectsAnAlgorithmThatDoesNotMatchTheKind() { + var hashWithMac = new CreateApiConnectorVariableCommand("h", ApiVariableKind.HASH, "x", + ApiVariableAlgorithm.HMAC_SHA256, null, null, null, null, null, null); + + assertThatThrownBy(() -> service.create(connectorId, orgId, hashWithMac)) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("algorithm_invalid"); + } + + @Test + void forbidsAnAlgorithmOnAKindThatHasNone() { + var constantWithAlgorithm = new CreateApiConnectorVariableCommand("v", + ApiVariableKind.CONSTANT, "x", ApiVariableAlgorithm.SHA256, null, null, null, + null, null, null); + + assertThatThrownBy(() -> service.create(connectorId, orgId, constantWithAlgorithm)) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("algorithm_forbidden"); + } + + @Test + void requiresAnEncodingForEncode() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("e", ApiVariableKind.ENCODE, "x"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("encoding_required"); + } + + /** CONSTANT ignores encoding by design; accepting one would be silently ineffective. */ + @Test + void forbidsAnEncodingOnConstant() { + var constantWithEncoding = new CreateApiConnectorVariableCommand("v", + ApiVariableKind.CONSTANT, "x", null, ApiVariableEncoding.BASE64, null, null, + null, null, null); + + assertThatThrownBy(() -> service.create(connectorId, orgId, constantWithEncoding)) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("encoding_forbidden"); + } + + @Test + void requiresASecretForHmac() { + var hmacNoSecret = new CreateApiConnectorVariableCommand("sig", ApiVariableKind.HMAC, "x", + ApiVariableAlgorithm.HMAC_SHA256, null, null, null, null, null, null); + + assertThatThrownBy(() -> service.create(connectorId, orgId, hmacNoSecret)) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("secret_required"); + } + + @Test + void forbidsASecretOnANonHmacKind() { + var constantWithSecret = new CreateApiConnectorVariableCommand("v", + ApiVariableKind.CONSTANT, "x", null, null, "k", null, null, null, null); + + assertThatThrownBy(() -> service.create(connectorId, orgId, constantWithSecret)) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("secret_forbidden"); + } + + @Test + void rejectsAnOutOfRangeRandomHexSize() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("n", ApiVariableKind.RANDOM_HEX, "999"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("random_hex_size"); + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("n", ApiVariableKind.RANDOM_HEX, "nope"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("random_hex_size"); + } + } + + @Nested + class Secrets { + + private CreateApiConnectorVariableCommand hmac(String secret) { + return new CreateApiConnectorVariableCommand("sig", ApiVariableKind.HMAC, "{{request.body}}", + ApiVariableAlgorithm.HMAC_SHA256, ApiVariableEncoding.HEX, secret, null, null, + null, null); + } + + @Test + void encryptsTheSecretAndReportsOnlyItsPresence() { + var view = service.create(connectorId, orgId, hmac("shared-key")); + + assertThat(view.hasSecret()).isTrue(); + verify(encryptionService).encrypt("shared-key"); + } + + @Test + void leavesTheStoredSecretAloneWhenTheUpdateOmitsIt() { + var entity = stored("sig", ApiVariableKind.HMAC, "{{request.body}}"); + entity.setAlgorithm(ApiVariableAlgorithm.HMAC_SHA256); + entity.setSecretEncrypted("enc:old"); + existing(entity); + + service.update(entity.getId(), connectorId, orgId, new UpdateApiConnectorVariableCommand( + "sig", ApiVariableKind.HMAC, "{{request.body}}", ApiVariableAlgorithm.HMAC_SHA256, + null, null, null, null, null, null, null)); + + assertThat(entity.getSecretEncrypted()).isEqualTo("enc:old"); + } + + @Test + void replacesTheSecretWhenOneIsSupplied() { + var entity = stored("sig", ApiVariableKind.HMAC, "{{request.body}}"); + entity.setAlgorithm(ApiVariableAlgorithm.HMAC_SHA256); + entity.setSecretEncrypted("enc:old"); + existing(entity); + + service.update(entity.getId(), connectorId, orgId, new UpdateApiConnectorVariableCommand( + "sig", ApiVariableKind.HMAC, "{{request.body}}", ApiVariableAlgorithm.HMAC_SHA256, + null, "new-key", null, null, null, null, null)); + + assertThat(entity.getSecretEncrypted()).isEqualTo("enc:new-key"); + } + + @Test + void clearingTheSecretOfAnHmacIsRejectedBecauseItWouldBeUnresolvable() { + var entity = stored("sig", ApiVariableKind.HMAC, "{{request.body}}"); + entity.setAlgorithm(ApiVariableAlgorithm.HMAC_SHA256); + entity.setSecretEncrypted("enc:old"); + existing(entity); + + assertThatThrownBy(() -> service.update(entity.getId(), connectorId, orgId, + new UpdateApiConnectorVariableCommand("sig", ApiVariableKind.HMAC, + "{{request.body}}", ApiVariableAlgorithm.HMAC_SHA256, null, null, true, + null, null, null, null))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("secret_required"); + } + } + + @Nested + class Overridable { + + /** A submitter must never be able to override a value that is a secret. */ + @Test + void rejectsMarkingAnHmacVariableOverridable() { + var command = new CreateApiConnectorVariableCommand("sig", ApiVariableKind.HMAC, + "{{request.body}}", ApiVariableAlgorithm.HMAC_SHA256, ApiVariableEncoding.HEX, + "k", null, true, null, null); + + assertThatThrownBy(() -> service.create(connectorId, orgId, command)) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("overridable_secret"); + } + + @Test + void allowsMarkingANonSecretVariableOverridable() { + var command = new CreateApiConnectorVariableCommand("nonce", ApiVariableKind.RANDOM_HEX, + null, null, null, null, null, true, null, null); + + assertThat(service.create(connectorId, orgId, command).overridable()).isTrue(); + } + + @Test + void defaultsToNotOverridable() { + assertThat(service.create(connectorId, orgId, create("v", ApiVariableKind.CONSTANT, "x")) + .overridable()).isFalse(); + } + } + + @Nested + class Targets { + + private CreateApiConnectorVariableCommand withTarget(String name, String target) { + return new CreateApiConnectorVariableCommand(name, ApiVariableKind.EPOCH_MILLIS, null, + null, null, null, target, null, null, null); + } + + @Test + void acceptsAWellFormedHeaderTarget() { + assertThat(service.create(connectorId, orgId, withTarget("ts", "header:X-Timestamp")) + .target()).isEqualTo("header:X-Timestamp"); + } + + @Test + void rejectsAMalformedTarget() { + assertThatThrownBy(() -> service.create(connectorId, orgId, withTarget("ts", "body"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("target_invalid"); + } + + @Test + void rejectsTwoVariablesInjectingIntoTheSameHeader() { + var other = stored("ts2", ApiVariableKind.EPOCH_MILLIS, null); + other.setTarget("header:X-Timestamp"); + existing(other); + + assertThatThrownBy(() -> service.create(connectorId, orgId, + withTarget("ts", "header:x-timestamp"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("target_duplicate"); + } + + @Test + void allowsTheSameKeyInDifferentTargetTypes() { + var other = stored("q", ApiVariableKind.EPOCH_MILLIS, null); + other.setTarget("query:ts"); + existing(other); + + assertThat(service.create(connectorId, orgId, withTarget("h", "header:ts")).target()) + .isEqualTo("header:ts"); + } + } + + @Nested + class Graph { + + @Test + void rejectsACycleIntroducedByTheCandidate() { + existing(stored("a", ApiVariableKind.CONSTANT, "{{b}}")); + + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("b", ApiVariableKind.CONSTANT, "{{a}}"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("cycle"); + } + + @Test + void rejectsASelfReference() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("a", ApiVariableKind.CONSTANT, "{{a}}"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("cycle"); + } + + @Test + void rejectsAQualifiedReferenceToAnUnknownVariable() { + assertThatThrownBy(() -> service.create(connectorId, orgId, + create("a", ApiVariableKind.CONSTANT, "{{var.nope}}"))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("unknown_reference"); + } + + @Test + void acceptsABareReferenceToAnUnknownName() { + // It stays literal at render time, so it is not a dangling dependency. + assertThat(service.create(connectorId, orgId, + create("a", ApiVariableKind.CONSTANT, "{{handlebars}}")).name()).isEqualTo("a"); + } + + @Test + void rejectsACycleIntroducedByAnUpdate() { + var a = stored("a", ApiVariableKind.CONSTANT, "{{b}}"); + var b = stored("b", ApiVariableKind.CONSTANT, "plain"); + existing(a, b); + + assertThatThrownBy(() -> service.update(b.getId(), connectorId, orgId, + new UpdateApiConnectorVariableCommand("b", ApiVariableKind.CONSTANT, "{{a}}", + null, null, null, null, null, null, null, null))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("cycle"); + } + } + + @Nested + class Deletion { + + @Test + void deletesAnUnreferencedVariable() { + var v = stored("v", ApiVariableKind.CONSTANT, "x"); + existing(v); + + service.delete(v.getId(), connectorId, orgId); + + verify(variableRepository).delete(v); + } + + @Test + void refusesToDeleteAVariableAnotherOneReferences() { + var base = stored("base", ApiVariableKind.CONSTANT, "x"); + var dependent = stored("dep", ApiVariableKind.CONSTANT, "{{base}}"); + existing(base, dependent); + + assertThatThrownBy(() -> service.delete(base.getId(), connectorId, orgId)) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("referenced"); + verify(variableRepository, never()).delete(any()); + } + + /** A rename orphans every reference to the old name, so it is guarded like a delete. */ + @Test + void refusesToRenameAVariableAnotherOneReferences() { + var base = stored("base", ApiVariableKind.CONSTANT, "x"); + var dependent = stored("dep", ApiVariableKind.CONSTANT, "{{base}}"); + existing(base, dependent); + + assertThatThrownBy(() -> service.update(base.getId(), connectorId, orgId, + new UpdateApiConnectorVariableCommand("renamed", ApiVariableKind.CONSTANT, "x", + null, null, null, null, null, null, null, null))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("referenced"); + } + } + + @Nested + class Ordering { + + @Test + void assignsTheNextSortOrderOnCreate() { + var first = stored("a", ApiVariableKind.CONSTANT, "x"); + first.setSortOrder(4); + existing(first); + + assertThat(service.create(connectorId, orgId, create("b", ApiVariableKind.CONSTANT, "y")) + .sortOrder()).isEqualTo(5); + } + + @Test + void reassignsSortOrderFromTheRequestedSequence() { + var a = stored("a", ApiVariableKind.CONSTANT, "x"); + var b = stored("b", ApiVariableKind.CONSTANT, "y"); + existing(a, b); + + service.reorder(connectorId, orgId, + new ReorderApiConnectorVariablesCommand(List.of(b.getId(), a.getId()))); + + assertThat(b.getSortOrder()).isZero(); + assertThat(a.getSortOrder()).isEqualTo(1); + } + + /** A partial reorder would leave the rest at stale positions — a silent surprise. */ + @Test + void rejectsAnIncompleteIdList() { + var a = stored("a", ApiVariableKind.CONSTANT, "x"); + existing(a, stored("b", ApiVariableKind.CONSTANT, "y")); + + assertThatThrownBy(() -> service.reorder(connectorId, orgId, + new ReorderApiConnectorVariablesCommand(List.of(a.getId())))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("reorder_incomplete"); + } + + @Test + void rejectsAnIdListContainingAForeignId() { + var a = stored("a", ApiVariableKind.CONSTANT, "x"); + existing(a); + + assertThatThrownBy(() -> service.reorder(connectorId, orgId, + new ReorderApiConnectorVariablesCommand(List.of(UUID.randomUUID())))) + .isInstanceOf(IllegalApiConnectorVariableException.class) + .hasMessageContaining("reorder_incomplete"); + } + } + + @Nested + class Listing { + + @Test + void mapsStoredRowsToViewsWithoutTheSecret() { + var v = stored("sig", ApiVariableKind.HMAC, "{{request.body}}"); + v.setAlgorithm(ApiVariableAlgorithm.HMAC_SHA256); + v.setSecretEncrypted("enc:k"); + v.setDescription("Vendor signature"); + existing(v); + + assertThat(service.listForConnector(connectorId, orgId)).singleElement().satisfies(view -> { + assertThat(view.name()).isEqualTo("sig"); + assertThat(view.hasSecret()).isTrue(); + assertThat(view.description()).isEqualTo("Vendor signature"); + }); + } + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableResolutionServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableResolutionServiceTest.java new file mode 100644 index 00000000..de93ac71 --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiConnectorVariableResolutionServiceTest.java @@ -0,0 +1,335 @@ +package com.bablsoft.accessflow.apigov.internal; + +import com.bablsoft.accessflow.apigov.api.ApiExecutionException; +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import com.bablsoft.accessflow.apigov.api.ApiVariableRequestContext; +import com.bablsoft.accessflow.apigov.api.ApiVariableTargetType; +import com.bablsoft.accessflow.apigov.internal.config.ApigovRequestProperties; +import com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiConnectorVariableEntity; +import com.bablsoft.accessflow.apigov.internal.persistence.repo.ApiConnectorVariableRepository; +import com.bablsoft.accessflow.core.api.CredentialEncryptionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.context.support.StaticMessageSource; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DefaultApiConnectorVariableResolutionServiceTest { + + private static final Instant NOW = Instant.parse("2026-07-20T10:30:45Z"); + + @Mock private ApiConnectorVariableRepository variableRepository; + @Mock private CredentialEncryptionService encryptionService; + + private DefaultApiConnectorVariableResolutionService service; + + private final UUID orgId = UUID.randomUUID(); + private final UUID connectorId = UUID.randomUUID(); + + @BeforeEach + void setUp() { + var messages = new StaticMessageSource(); + messages.setUseCodeAsDefaultMessage(true); + service = new DefaultApiConnectorVariableResolutionService(variableRepository, + encryptionService, new ApiVariableEvaluator(Clock.fixed(NOW, ZoneOffset.UTC)), + new ApigovRequestProperties(1L, 1L, 1L, 8192), messages); + // The stored secret is AES-encrypted; the test double is an identity transform. + when(encryptionService.decrypt(anyString())).thenAnswer(inv -> inv.getArgument(0)); + } + + private ApiConnectorVariableEntity variable(String name, ApiVariableKind kind, String expression) { + var e = new ApiConnectorVariableEntity(); + e.setId(UUID.randomUUID()); + e.setOrganizationId(orgId); + e.setConnectorId(connectorId); + e.setName(name); + e.setKind(kind); + e.setExpression(expression); + return e; + } + + private void stored(ApiConnectorVariableEntity... entities) { + when(variableRepository + .findAllByOrganizationIdAndConnectorIdOrderBySortOrderAscCreatedAtAscIdAsc(any(), any())) + .thenReturn(List.of(entities)); + } + + private ApiVariableRequestContext context(String body, Map headers) { + return new ApiVariableRequestContext("POST", "/v1/pay", "a=1", body, headers); + } + + @Test + void returnsEmptyWhenTheConnectorHasNoVariables() { + stored(); + + var resolved = service.resolve(orgId, connectorId, context("", Map.of()), Map.of()); + + assertThat(resolved.isEmpty()).isTrue(); + } + + /** + * The motivating vendor scheme, end to end: the signature covers the resolved Authorization + * header concatenated with the request body, where the body still carries the literal + * {@code {{signature}}} placeholder. The digest is substituted back in afterwards, which is why + * the request context must describe the request before substitution. + */ + @Test + void computesTheVendorHmacOverTheAuthHeaderAndThePlaceholderBody() throws Exception { + var signature = variable("signature", ApiVariableKind.HMAC, + "{{request.headers.Authorization}}{{request.body}}"); + signature.setAlgorithm(ApiVariableAlgorithm.HMAC_SHA256); + signature.setEncoding(ApiVariableEncoding.HEX); + signature.setSecretEncrypted("shared-key"); + stored(signature); + + var body = "{\"data\":\"example_data\",\"HMAC\":\"{{signature}}\"}"; + var authHeader = "Basic token_value"; + + var resolved = service.resolve(orgId, connectorId, + context(body, Map.of("Authorization", authHeader)), Map.of()); + + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec("shared-key".getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + var expected = HexFormat.of() + .formatHex(mac.doFinal((authHeader + body).getBytes(StandardCharsets.UTF_8))); + + assertThat(resolved.values()).containsEntry("signature", expected); + } + + @Test + void matchesRequestHeaderNamesCaseInsensitively() { + var v = variable("echo", ApiVariableKind.CONSTANT, "{{request.headers.authorization}}"); + stored(v); + + var resolved = service.resolve(orgId, connectorId, + context("", Map.of("Authorization", "Bearer t")), Map.of()); + + assertThat(resolved.values()).containsEntry("echo", "Bearer t"); + } + + @Test + void exposesEveryRequestContextField() { + var v = variable("all", ApiVariableKind.CONSTANT, + "{{request.method}}|{{request.path}}|{{request.query}}|{{request.body}}"); + stored(v); + + var resolved = service.resolve(orgId, connectorId, context("BODY", Map.of()), Map.of()); + + assertThat(resolved.values()).containsEntry("all", "POST|/v1/pay|a=1|BODY"); + } + + @Test + void resolvesVariablesInDependencyOrder() { + var nonce = variable("nonce", ApiVariableKind.CONSTANT, "N1"); + var wrapped = variable("wrapped", ApiVariableKind.CONSTANT, "[{{nonce}}]"); + wrapped.setSortOrder(0); + nonce.setSortOrder(1); + stored(wrapped, nonce); + + var resolved = service.resolve(orgId, connectorId, context("", Map.of()), Map.of()); + + assertThat(resolved.values()).containsEntry("wrapped", "[N1]"); + } + + @Test + void buildsInjectionsFromTargets() { + var v = variable("ts", ApiVariableKind.EPOCH_MILLIS, null); + v.setTarget("header:X-Timestamp"); + stored(v); + + var resolved = service.resolve(orgId, connectorId, context("", Map.of()), Map.of()); + + assertThat(resolved.injections()).singleElement().satisfies(i -> { + assertThat(i.type()).isEqualTo(ApiVariableTargetType.HEADER); + assertThat(i.key()).isEqualTo("X-Timestamp"); + assertThat(i.value()).isEqualTo(Long.toString(NOW.toEpochMilli())); + }); + } + + @Test + void emitsNoInjectionForAVariableWithoutATarget() { + stored(variable("v", ApiVariableKind.CONSTANT, "x")); + + assertThat(service.resolve(orgId, connectorId, context("", Map.of()), Map.of()).injections()) + .isEmpty(); + } + + @Test + void failsWhenAnHmacVariableHasNoSecret() { + var v = variable("sig", ApiVariableKind.HMAC, "data"); + v.setAlgorithm(ApiVariableAlgorithm.HMAC_SHA256); + stored(v); + + assertThatThrownBy(() -> service.resolve(orgId, connectorId, context("", Map.of()), Map.of())) + .isInstanceOf(ApiExecutionException.class) + .hasMessageContaining("error.api_connector_variable_secret_required"); + } + + @Test + void failsOnACycleThatSurvivedSaveTimeValidation() { + stored(variable("a", ApiVariableKind.CONSTANT, "{{b}}"), + variable("b", ApiVariableKind.CONSTANT, "{{a}}")); + + assertThatThrownBy(() -> service.resolve(orgId, connectorId, context("", Map.of()), Map.of())) + .isInstanceOf(ApiExecutionException.class) + .hasMessageContaining("error.api_connector_variable_cycle"); + } + + @Test + void failsOnAQualifiedReferenceToAnUnknownVariable() { + stored(variable("a", ApiVariableKind.CONSTANT, "{{var.gone}}")); + + assertThatThrownBy(() -> service.resolve(orgId, connectorId, context("", Map.of()), Map.of())) + .isInstanceOf(ApiExecutionException.class) + .hasMessageContaining("error.api_connector_variable_unknown_reference"); + } + + @Test + void failsWhenAResolvedValueExceedsTheSizeCap() { + var messages = new StaticMessageSource(); + messages.setUseCodeAsDefaultMessage(true); + var tiny = new DefaultApiConnectorVariableResolutionService(variableRepository, + encryptionService, new ApiVariableEvaluator(Clock.fixed(NOW, ZoneOffset.UTC)), + new ApigovRequestProperties(1L, 1L, 1L, 4), messages); + stored(variable("v", ApiVariableKind.CONSTANT, "far too long")); + + assertThatThrownBy(() -> tiny.resolve(orgId, connectorId, context("", Map.of()), Map.of())) + .isInstanceOf(ApiExecutionException.class) + .hasMessageContaining("error.api_connector_variable_value_too_large"); + } + + /** CR / LF / NUL in a value bound for a header is request splitting. */ + @Test + void failsWhenAResolvedValueCarriesControlCharacters() { + stored(variable("v", ApiVariableKind.CONSTANT, "{{request.body}}")); + + assertThatThrownBy(() -> service.resolve(orgId, connectorId, + context("a\r\nX-Evil: 1", Map.of()), Map.of())) + .isInstanceOf(ApiExecutionException.class) + .hasMessageContaining("error.api_connector_variable_value_invalid"); + } + + @Test + void detectsEachControlCharacter() { + assertThat(DefaultApiConnectorVariableResolutionService.containsControlCharacters("a\rb")).isTrue(); + assertThat(DefaultApiConnectorVariableResolutionService.containsControlCharacters("a\nb")).isTrue(); + assertThat(DefaultApiConnectorVariableResolutionService.containsControlCharacters("a\0b")).isTrue(); + assertThat(DefaultApiConnectorVariableResolutionService.containsControlCharacters("ab")).isFalse(); + } + + @Test + void appliesAnOverrideToAnOverridableVariable() { + var v = variable("nonce", ApiVariableKind.RANDOM_HEX, null); + v.setOverridable(true); + stored(v); + + var resolved = service.resolve(orgId, connectorId, context("", Map.of()), + Map.of("nonce", "fixed-nonce")); + + assertThat(resolved.values()).containsEntry("nonce", "fixed-nonce"); + } + + /** An override replaces the value outright; the kind is not re-applied over it. */ + @Test + void anOverrideReplacesTheValueWithoutReapplyingTheKind() { + var v = variable("id", ApiVariableKind.UUID, null); + v.setOverridable(true); + stored(v); + + var resolved = service.resolve(orgId, connectorId, context("", Map.of()), Map.of("id", "abc")); + + assertThat(resolved.values()).containsEntry("id", "abc"); + } + + @Test + void ignoresAnOverrideTargetingANonOverridableVariable() { + // The submit path already rejects these; the resolver is belt-and-braces. + stored(variable("locked", ApiVariableKind.CONSTANT, "real")); + + var resolved = service.resolve(orgId, connectorId, context("", Map.of()), + Map.of("locked", "spoofed")); + + assertThat(resolved.values()).containsEntry("locked", "real"); + } + + @Test + void dependentsRecomputeOverAnOverriddenValue() { + var nonce = variable("nonce", ApiVariableKind.CONSTANT, "generated"); + nonce.setOverridable(true); + var wrapper = variable("wrapper", ApiVariableKind.CONSTANT, "[{{nonce}}]"); + stored(nonce, wrapper); + + var resolved = service.resolve(orgId, connectorId, context("", Map.of()), + Map.of("nonce", "supplied")); + + assertThat(resolved.values()).containsEntry("wrapper", "[supplied]"); + } + + /** + * The containment property behind per-request overrides: an override is an opaque literal, never + * re-rendered, so it cannot expand into another variable's (possibly secret-derived) value. + */ + @Test + void anOverrideIsNeverExpandedAsATemplate() { + var secret = variable("signingKey", ApiVariableKind.CONSTANT, "s3cr3t"); + var open = variable("label", ApiVariableKind.CONSTANT, "plain"); + open.setOverridable(true); + stored(secret, open); + + var resolved = service.resolve(orgId, connectorId, context("", Map.of()), + Map.of("label", "{{signingKey}}")); + + assertThat(resolved.values()).containsEntry("label", "{{signingKey}}"); + } + + @Test + void summariesCarryNoExpressionAlgorithmOrSecret() { + var v = variable("sig", ApiVariableKind.HMAC, "{{request.body}}"); + v.setAlgorithm(ApiVariableAlgorithm.HMAC_SHA256); + v.setSecretEncrypted("k"); + v.setDescription("Vendor signature"); + stored(v); + + var summaries = service.summariesForConnector(connectorId, orgId); + + assertThat(summaries).singleElement().satisfies(s -> { + assertThat(s.name()).isEqualTo("sig"); + assertThat(s.kind()).isEqualTo(ApiVariableKind.HMAC); + assertThat(s.description()).isEqualTo("Vendor signature"); + assertThat(s.overridable()).isFalse(); + }); + } + + @Test + void overridableNamesListsOnlyTheOptedInVariables() { + var open = variable("nonce", ApiVariableKind.RANDOM_HEX, null); + open.setOverridable(true); + stored(open, variable("locked", ApiVariableKind.CONSTANT, "x")); + + assertThat(service.overridableNames(connectorId, orgId)).containsExactly("nonce"); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiRequestServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiRequestServiceTest.java index 0195bae6..92ffbf8c 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiRequestServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiRequestServiceTest.java @@ -59,6 +59,9 @@ class DefaultApiRequestServiceTest { @Mock private AuditLogService auditLogService; @Mock private ApplicationEventPublisher eventPublisher; + @Mock private com.bablsoft.accessflow.apigov.api.ApiConnectorVariableLookupService + variableLookupService; + private DefaultApiRequestService service; private final UUID orgId = UUID.randomUUID(); @@ -69,8 +72,8 @@ class DefaultApiRequestServiceTest { void setUp() { service = new DefaultApiRequestService(requestRepository, connectorRepository, permissionResolver, decisionRepository, schemaService, stateService, executionService, - aiAnalysisLookupService, userQueryService, - new ApigovRequestProperties(5_242_880L, 10_485_760L, 65_536L), + aiAnalysisLookupService, userQueryService, variableLookupService, + new ApigovRequestProperties(5_242_880L, 10_485_760L, 65_536L, 8192), auditLogService, eventPublisher, JsonMapper.builder().build()); lenient().when(schemaService.listOperations(any(), any())).thenReturn(List.of()); lenient().when(userQueryService.findById(any())).thenReturn(Optional.empty()); @@ -87,13 +90,24 @@ private ApiConnectorEntity connector() { } private ResolvedApiConnectorPermission permission(boolean read, boolean write, boolean breakGlass) { - return new ResolvedApiConnectorPermission(connectorId, userId, read, write, breakGlass, + return new ResolvedApiConnectorPermission(connectorId, userId, read, write, breakGlass, false, + List.of(), List.of(), null); + } + + private ResolvedApiConnectorPermission permissionWithOverrides(boolean canOverride) { + return new ResolvedApiConnectorPermission(connectorId, userId, true, true, false, canOverride, List.of(), List.of(), null); } + private SubmitApiRequestCommand cmdWithOverrides(java.util.Map overrides) { + return new SubmitApiRequestCommand(connectorId, orgId, userId, false, null, "POST", "/charges", + null, null, ApiBodyType.RAW, "application/json", "{}", null, null, overrides, "need", + null, SubmissionReason.USER_SUBMITTED, "1.2.3.4", "ua"); + } + private SubmitApiRequestCommand cmd(String verb, SubmissionReason reason) { return new SubmitApiRequestCommand(connectorId, orgId, userId, false, null, verb, "/charges", - null, null, ApiBodyType.RAW, "application/json", "{}", null, null, "need", null, reason, + null, null, ApiBodyType.RAW, "application/json", "{}", null, null, java.util.Map.of(), "need", null, reason, "1.2.3.4", "ua"); } @@ -270,8 +284,8 @@ void downloadResponseAllowsReviewer() { void detailViewSlicesSnapshotToPreviewWhileDownloadKeepsFullBody() { service = new DefaultApiRequestService(requestRepository, connectorRepository, permissionResolver, decisionRepository, schemaService, stateService, executionService, - aiAnalysisLookupService, userQueryService, - new ApigovRequestProperties(5_242_880L, 10_485_760L, 8L), + aiAnalysisLookupService, userQueryService, variableLookupService, + new ApigovRequestProperties(5_242_880L, 10_485_760L, 8L, 8192), auditLogService, eventPublisher, JsonMapper.builder().build()); lenient().when(schemaService.listOperations(any(), any())).thenReturn(List.of()); var full = "0123456789ABCDEF"; // 16 chars, longer than the 8-char preview budget @@ -338,15 +352,15 @@ void submitGeneratesTraceAndSpanIds() { void submitRejectsBodyOverSizeCap() { service = new DefaultApiRequestService(requestRepository, connectorRepository, permissionResolver, decisionRepository, schemaService, stateService, executionService, - aiAnalysisLookupService, userQueryService, - new ApigovRequestProperties(4L, 10_485_760L, 65_536L), + aiAnalysisLookupService, userQueryService, variableLookupService, + new ApigovRequestProperties(4L, 10_485_760L, 65_536L, 8192), auditLogService, eventPublisher, JsonMapper.builder().build()); lenient().when(schemaService.listOperations(any(), any())).thenReturn(List.of()); when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)).thenReturn(Optional.of(connector())); when(permissionResolver.resolve(connectorId, userId)) .thenReturn(Optional.of(permission(true, true, false))); var command = new SubmitApiRequestCommand(connectorId, orgId, userId, false, null, "POST", "/charges", - null, null, ApiBodyType.RAW, "text/plain", "way too long", List.of(), null, "need", null, + null, null, ApiBodyType.RAW, "text/plain", "way too long", List.of(), null, java.util.Map.of(), "need", null, SubmissionReason.USER_SUBMITTED, "1.2.3.4", "ua"); assertThatThrownBy(() -> service.submit(command)).isInstanceOf(ApiRequestValidationException.class); @@ -403,4 +417,133 @@ void downloadResponseDeniesNonOwnerNonAdmin() { assertThatThrownBy(() -> service.downloadResponse(e.getId(), orgId, userId, SystemRolePermissions.of(UserRoleType.ANALYST))) .isInstanceOf(com.bablsoft.accessflow.apigov.api.ApiRequestNotFoundException.class); } + + // --- AF-613: per-request variable overrides ------------------------------------------------- + + @Test + void submitPersistsVariableOverridesWhenPermitted() { + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector())); + when(permissionResolver.resolve(connectorId, userId)) + .thenReturn(Optional.of(permissionWithOverrides(true))); + when(variableLookupService.overridableNames(connectorId, orgId)) + .thenReturn(java.util.Set.of("nonce")); + when(requestRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + service.submit(cmdWithOverrides(java.util.Map.of("nonce", "fixed"))); + + var saved = org.mockito.ArgumentCaptor.forClass( + com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiRequestEntity.class); + verify(requestRepository).save(saved.capture()); + assertThat(saved.getValue().getVariableOverrides()).contains("nonce").contains("fixed"); + } + + @Test + void submitStoresAnEmptyObjectWhenNoOverridesAreSupplied() { + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector())); + when(permissionResolver.resolve(connectorId, userId)) + .thenReturn(Optional.of(permission(true, true, false))); + when(requestRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + service.submit(cmd("POST", SubmissionReason.USER_SUBMITTED)); + + var saved = org.mockito.ArgumentCaptor.forClass( + com.bablsoft.accessflow.apigov.internal.persistence.entity.ApiRequestEntity.class); + verify(requestRepository).save(saved.capture()); + assertThat(saved.getValue().getVariableOverrides()).isEqualTo("{}"); + } + + @Test + void submitRejectsOverridesWithoutTheOverridePermission() { + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector())); + when(permissionResolver.resolve(connectorId, userId)) + .thenReturn(Optional.of(permissionWithOverrides(false))); + + assertThatThrownBy(() -> service.submit(cmdWithOverrides(java.util.Map.of("nonce", "x")))) + .isInstanceOf(com.bablsoft.accessflow.apigov.api.ApiRequestPermissionException.class); + verify(requestRepository, org.mockito.Mockito.never()).save(any()); + } + + /** + * Unknown, not-overridable and secret-bearing all produce the same message. Distinct + * ones would let a submitter enumerate which of a connector variables hold secrets. + */ + @Test + void submitRejectsANameOutsideTheOverridableSetWithAUniformMessage() { + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector())); + when(permissionResolver.resolve(connectorId, userId)) + .thenReturn(Optional.of(permissionWithOverrides(true))); + when(variableLookupService.overridableNames(connectorId, orgId)) + .thenReturn(java.util.Set.of("nonce")); + + assertThatThrownBy(() -> service.submit(cmdWithOverrides(java.util.Map.of("signingKey", "x")))) + .isInstanceOf(com.bablsoft.accessflow.apigov.api.ApiRequestValidationException.class) + .hasMessageContaining("Connector variable override not permitted: signingKey"); + assertThatThrownBy(() -> service.submit(cmdWithOverrides(java.util.Map.of("noSuchThing", "x")))) + .isInstanceOf(com.bablsoft.accessflow.apigov.api.ApiRequestValidationException.class) + .hasMessageContaining("Connector variable override not permitted: noSuchThing"); + } + + /** CR / LF in an override bound for a header is request splitting — reject at submit, not at run. */ + @Test + void submitRejectsAnOverrideCarryingControlCharacters() { + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector())); + when(permissionResolver.resolve(connectorId, userId)) + .thenReturn(Optional.of(permissionWithOverrides(true))); + when(variableLookupService.overridableNames(connectorId, orgId)) + .thenReturn(java.util.Set.of("nonce")); + + assertThatThrownBy(() -> service.submit( + cmdWithOverrides(java.util.Map.of("nonce", "ok\r\nX-Evil: 1")))) + .isInstanceOf(com.bablsoft.accessflow.apigov.api.ApiRequestValidationException.class) + .hasMessageContaining("Invalid override value for variable nonce"); + } + + @Test + void submitRejectsAnOversizedOverrideValue() { + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector())); + when(permissionResolver.resolve(connectorId, userId)) + .thenReturn(Optional.of(permissionWithOverrides(true))); + when(variableLookupService.overridableNames(connectorId, orgId)) + .thenReturn(java.util.Set.of("nonce")); + + assertThatThrownBy(() -> service.submit( + cmdWithOverrides(java.util.Map.of("nonce", "x".repeat(8193))))) + .isInstanceOf(com.bablsoft.accessflow.apigov.api.ApiRequestValidationException.class) + .hasMessageContaining("Invalid override value for variable nonce"); + } + + @Test + void submitRejectsTooManyOverrides() { + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector())); + when(permissionResolver.resolve(connectorId, userId)) + .thenReturn(Optional.of(permissionWithOverrides(true))); + var many = new java.util.HashMap(); + for (var i = 0; i < 33; i++) { + many.put("v" + i, "x"); + } + + assertThatThrownBy(() -> service.submit(cmdWithOverrides(many))) + .isInstanceOf(com.bablsoft.accessflow.apigov.api.ApiRequestValidationException.class) + .hasMessageContaining("variable overrides may be supplied"); + } + + @Test + void submitDoesNotConsultTheLookupServiceWhenThereAreNoOverrides() { + when(connectorRepository.findByIdAndOrganizationId(connectorId, orgId)) + .thenReturn(Optional.of(connector())); + when(permissionResolver.resolve(connectorId, userId)) + .thenReturn(Optional.of(permission(true, true, false))); + when(requestRepository.save(any())).thenAnswer(i -> i.getArgument(0)); + + service.submit(cmdWithOverrides(java.util.Map.of())); + + verify(variableLookupService, org.mockito.Mockito.never()).overridableNames(any(), any()); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiReviewServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiReviewServiceTest.java index e197be86..0f05e33a 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiReviewServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/DefaultApiReviewServiceTest.java @@ -51,7 +51,8 @@ class DefaultApiReviewServiceTest { @BeforeEach void setUp() { service = new DefaultApiReviewService(requestRepository, decisionRepository, connectorRepository, - stateService, aiAnalysisLookupService, eventPublisher); + stateService, aiAnalysisLookupService, eventPublisher, + tools.jackson.databind.json.JsonMapper.builder().build()); } private ApiRequestEntity pending() { diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/config/ApigovRequestPropertiesTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/config/ApigovRequestPropertiesTest.java index 716e6e7a..0c90a692 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/config/ApigovRequestPropertiesTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/config/ApigovRequestPropertiesTest.java @@ -8,7 +8,7 @@ class ApigovRequestPropertiesTest { @Test void keepsPositiveValues() { - var props = new ApigovRequestProperties(1024L, 2048L, 512L); + var props = new ApigovRequestProperties(1024L, 2048L, 512L, 8192); assertThat(props.maxRequestBodyBytes()).isEqualTo(1024L); assertThat(props.maxResponseBytes()).isEqualTo(2048L); assertThat(props.responsePreviewBytes()).isEqualTo(512L); @@ -16,12 +16,12 @@ void keepsPositiveValues() { @Test void defaultsWhenNonPositive() { - var zeroed = new ApigovRequestProperties(0L, 0L, 0L); + var zeroed = new ApigovRequestProperties(0L, 0L, 0L, 8192); assertThat(zeroed.maxRequestBodyBytes()).isEqualTo(5L * 1024 * 1024); assertThat(zeroed.maxResponseBytes()).isEqualTo(10L * 1024 * 1024); assertThat(zeroed.responsePreviewBytes()).isEqualTo(65_536L); - var negative = new ApigovRequestProperties(-1L, -1L, -1L); + var negative = new ApigovRequestProperties(-1L, -1L, -1L, 8192); assertThat(negative.maxRequestBodyBytes()).isEqualTo(5L * 1024 * 1024); assertThat(negative.maxResponseBytes()).isEqualTo(10L * 1024 * 1024); assertThat(negative.responsePreviewBytes()).isEqualTo(65_536L); diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorControllerTest.java index 9d319ce2..6fa588cd 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorControllerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorControllerTest.java @@ -40,12 +40,16 @@ class ApiConnectorControllerTest { private final UUID userId = UUID.randomUUID(); private final UUID connectorId = UUID.randomUUID(); private final RequestAuditContext auditContext = new RequestAuditContext("203.0.113.9", "ua/1"); + private com.bablsoft.accessflow.apigov.api.ApiConnectorVariableLookupService variableLookupService; @BeforeEach void setUp() { service = mock(ApiConnectorAdminService.class); auditLogService = mock(AuditLogService.class); - controller = new ApiConnectorController(service, new ApiGovAuditWriter(auditLogService)); + variableLookupService = + mock(com.bablsoft.accessflow.apigov.api.ApiConnectorVariableLookupService.class); + controller = new ApiConnectorController(service, variableLookupService, + new ApiGovAuditWriter(auditLogService)); } private Authentication auth(UserRoleType role) { @@ -154,7 +158,7 @@ void grantAndRevokeAudit() { var target = UUID.randomUUID(); when(service.grantPermission(eq(connectorId), eq(orgId), eq(userId), any())) .thenReturn(permissionView()); - controller.grant(connectorId, new GrantApiConnectorPermissionRequest(target, true, false, false, + controller.grant(connectorId, new GrantApiConnectorPermissionRequest(target, true, false, false, false, null, null, null), auth(UserRoleType.ADMIN), auditContext); verify(auditLogService).record(auditEntry(AuditAction.API_PERMISSION_GRANTED)); @@ -171,7 +175,7 @@ void updatePermissionDelegatesAndAudits() { .thenReturn(permissionView()); var response = controller.update(connectorId, permId, - new UpdateApiConnectorPermissionRequest(false, true, false, null, + new UpdateApiConnectorPermissionRequest(false, true, false, false, null, List.of("createPet"), List.of("data.token")), auth(UserRoleType.ADMIN), auditContext); @@ -197,7 +201,7 @@ void grantAndRevokeGroupAudit() { when(service.grantGroupPermission(eq(connectorId), eq(orgId), eq(userId), any())) .thenReturn(groupPermissionView()); controller.grantGroup(connectorId, - new GrantApiConnectorGroupPermissionRequest(groupId, true, false, false, null, null, null), + new GrantApiConnectorGroupPermissionRequest(groupId, true, false, false, false, null, null, null), auth(UserRoleType.ADMIN), auditContext); verify(auditLogService).record(auditEntry(AuditAction.API_PERMISSION_GROUP_GRANTED)); @@ -214,7 +218,7 @@ void updateGroupPermissionDelegatesAndAudits() { .thenReturn(groupPermissionView()); var response = controller.updateGroup(connectorId, permId, - new UpdateApiConnectorGroupPermissionRequest(true, false, false, null, + new UpdateApiConnectorGroupPermissionRequest(true, false, false, false, null, List.of("listPets"), List.of("data.ssn")), auth(UserRoleType.ADMIN), auditContext); @@ -225,12 +229,12 @@ void updateGroupPermissionDelegatesAndAudits() { private ApiConnectorPermissionView permissionView() { return new ApiConnectorPermissionView(UUID.randomUUID(), connectorId, UUID.randomUUID(), - "u@acme.test", "User", true, false, false, null, List.of(), List.of(), Instant.now()); + "u@acme.test", "User", true, false, false, false, null, List.of(), List.of(), Instant.now()); } private com.bablsoft.accessflow.apigov.api.ApiConnectorGroupPermissionView groupPermissionView() { return new com.bablsoft.accessflow.apigov.api.ApiConnectorGroupPermissionView(UUID.randomUUID(), - connectorId, UUID.randomUUID(), "Analysts", 3, true, false, false, null, + connectorId, UUID.randomUUID(), "Analysts", 3, true, false, false, false, null, List.of(), List.of(), Instant.now()); } diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableControllerTest.java new file mode 100644 index 00000000..9a04f6cb --- /dev/null +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiConnectorVariableControllerTest.java @@ -0,0 +1,173 @@ +package com.bablsoft.accessflow.apigov.internal.web; + +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableAdminService; +import com.bablsoft.accessflow.apigov.api.ApiConnectorVariableView; +import com.bablsoft.accessflow.apigov.api.ApiVariableAlgorithm; +import com.bablsoft.accessflow.apigov.api.ApiVariableEncoding; +import com.bablsoft.accessflow.apigov.api.ApiVariableKind; +import com.bablsoft.accessflow.audit.api.AuditAction; +import com.bablsoft.accessflow.audit.api.AuditLogService; +import com.bablsoft.accessflow.audit.api.RequestAuditContext; +import com.bablsoft.accessflow.core.api.UserRoleType; +import com.bablsoft.accessflow.security.api.JwtClaims; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.core.Authentication; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ApiConnectorVariableControllerTest { + + private ApiConnectorVariableAdminService service; + private AuditLogService auditLogService; + private ApiConnectorVariableController controller; + + private final UUID orgId = UUID.randomUUID(); + private final UUID userId = UUID.randomUUID(); + private final UUID connectorId = UUID.randomUUID(); + private final UUID variableId = UUID.randomUUID(); + private final RequestAuditContext auditContext = new RequestAuditContext("203.0.113.9", "ua/1"); + + @BeforeEach + void setUp() { + service = mock(ApiConnectorVariableAdminService.class); + auditLogService = mock(AuditLogService.class); + controller = new ApiConnectorVariableController(service, new ApiGovAuditWriter(auditLogService)); + } + + private Authentication auth() { + var authentication = mock(Authentication.class); + when(authentication.getPrincipal()) + .thenReturn(JwtClaims.forSystemRole(userId, "a@acme.test", UserRoleType.ADMIN, orgId)); + return authentication; + } + + private ApiConnectorVariableView view() { + return new ApiConnectorVariableView(variableId, connectorId, "signature", + ApiVariableKind.HMAC, "{{request.headers.Authorization}}{{request.body}}", + ApiVariableAlgorithm.HMAC_SHA256, ApiVariableEncoding.HEX, true, "header:X-Signature", + false, "Vendor signature", 0, Instant.now(), Instant.now()); + } + + private static CreateApiConnectorVariableRequest createRequest() { + return new CreateApiConnectorVariableRequest("signature", ApiVariableKind.HMAC, + "{{request.body}}", ApiVariableAlgorithm.HMAC_SHA256, ApiVariableEncoding.HEX, + "shared-key", "header:X-Signature", false, "Vendor signature", null); + } + + @Test + void listReturnsContent() { + when(service.listForConnector(connectorId, orgId)).thenReturn(List.of(view())); + + var response = controller.list(connectorId, auth()); + + assertThat(response.content()).hasSize(1); + assertThat(response.content().getFirst().name()).isEqualTo("signature"); + } + + /** The secret is write-only: the response reports its presence and never its value. */ + @Test + void responseCarriesOnlyTheSecretPresenceFlag() { + when(service.listForConnector(connectorId, orgId)).thenReturn(List.of(view())); + + var body = controller.list(connectorId, auth()).content().getFirst(); + + assertThat(body.hasSecret()).isTrue(); + assertThat(ApiConnectorVariableResponse.class.getRecordComponents()) + .noneMatch(c -> c.getName().toLowerCase(java.util.Locale.ROOT).contains("secret") + && !"hasSecret".equals(c.getName())); + } + + @Test + void createDelegatesAndAudits() { + when(service.create(eq(connectorId), eq(orgId), any())).thenReturn(view()); + + var response = controller.create(connectorId, createRequest(), auth(), auditContext); + + assertThat(response.name()).isEqualTo("signature"); + verify(auditLogService).record(argThatHasAction(AuditAction.API_CONNECTOR_VARIABLE_CREATED)); + } + + /** Audit metadata must describe the shape of the change, never the expression or the secret. */ + @Test + void auditMetadataOmitsTheExpressionAndSecret() { + when(service.create(eq(connectorId), eq(orgId), any())).thenReturn(view()); + + controller.create(connectorId, createRequest(), auth(), auditContext); + + var entry = ArgumentCaptor.forClass(com.bablsoft.accessflow.audit.api.AuditEntry.class); + verify(auditLogService).record(entry.capture()); + var metadata = entry.getValue().metadata(); + assertThat(metadata).containsKeys("variable_id", "name", "kind", "has_secret", "overridable"); + assertThat(metadata).doesNotContainKeys("expression", "secret"); + assertThat(metadata.toString()).doesNotContain("request.headers.Authorization"); + } + + @Test + void updateDelegatesAndAudits() { + when(service.update(eq(variableId), eq(connectorId), eq(orgId), any())).thenReturn(view()); + var body = new UpdateApiConnectorVariableRequest("signature", ApiVariableKind.HMAC, + "{{request.body}}", ApiVariableAlgorithm.HMAC_SHA256, ApiVariableEncoding.HEX, null, + null, "header:X-Signature", false, null, null); + + var response = controller.update(connectorId, variableId, body, auth(), auditContext); + + assertThat(response.id()).isEqualTo(variableId); + verify(service).update(eq(variableId), eq(connectorId), eq(orgId), any()); + verify(auditLogService).record(argThatHasAction(AuditAction.API_CONNECTOR_VARIABLE_UPDATED)); + } + + @Test + void deleteDelegatesAndAudits() { + controller.delete(connectorId, variableId, auth(), auditContext); + + verify(service).delete(variableId, connectorId, orgId); + verify(auditLogService).record(argThatHasAction(AuditAction.API_CONNECTOR_VARIABLE_DELETED)); + } + + @Test + void reorderDelegatesAndAudits() { + var order = List.of(variableId); + when(service.reorder(eq(connectorId), eq(orgId), any())).thenReturn(List.of(view())); + + var response = controller.reorder(connectorId, + new ReorderApiConnectorVariablesRequest(order), auth(), auditContext); + + assertThat(response.content()).hasSize(1); + verify(auditLogService).record(argThatHasAction(AuditAction.API_CONNECTOR_VARIABLES_REORDERED)); + } + + @Test + void requestsMapOntoCommandsFaithfully() { + var createCommand = createRequest().toCommand(); + + assertThat(createCommand.name()).isEqualTo("signature"); + assertThat(createCommand.kind()).isEqualTo(ApiVariableKind.HMAC); + assertThat(createCommand.secret()).isEqualTo("shared-key"); + assertThat(createCommand.target()).isEqualTo("header:X-Signature"); + + var updateCommand = new UpdateApiConnectorVariableRequest("v", ApiVariableKind.CONSTANT, "x", + null, null, "s", true, null, true, "d", 3).toCommand(); + + assertThat(updateCommand.clearSecret()).isTrue(); + assertThat(updateCommand.overridable()).isTrue(); + assertThat(updateCommand.sortOrder()).isEqualTo(3); + + assertThat(new ReorderApiConnectorVariablesRequest(List.of(variableId)).toCommand().variableIds()) + .containsExactly(variableId); + } + + private static com.bablsoft.accessflow.audit.api.AuditEntry argThatHasAction(AuditAction action) { + return org.mockito.ArgumentMatchers.argThat(entry -> entry.action() == action); + } +} diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiRequestControllerTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiRequestControllerTest.java index 097fc53b..67bcde4d 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiRequestControllerTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/ApiRequestControllerTest.java @@ -55,7 +55,7 @@ private Authentication auth(UserRoleType role) { private ApiRequestView view() { return new ApiRequestView(requestId, connectorId, "Stripe", userId, "u@acme.test", null, "POST", "/charges", true, QueryStatus.PENDING_AI, SubmissionReason.USER_SUBMITTED, "need", null, null, - null, null, com.bablsoft.accessflow.apigov.api.ApiBodyType.RAW, null, null, null, null, null, + null, null, com.bablsoft.accessflow.apigov.api.ApiBodyType.RAW, java.util.Map.of(), null, null, null, null, null, null, false, null, false, null, null, Instant.now(), List.of()); } @@ -67,7 +67,7 @@ void submitReturnsDetail() { .thenReturn(view()); var response = controller.submit(new SubmitApiRequestRequest(connectorId, null, "POST", "/charges", - null, null, null, null, "{}", null, null, "need", null, null), + null, null, null, null, "{}", null, null, java.util.Map.of(), "need", null, null), auth(UserRoleType.ANALYST), auditContext); assertThat(response.id()).isEqualTo(requestId); diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorGroupPermissionRequestTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorGroupPermissionRequestTest.java index 04178046..eaddd460 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorGroupPermissionRequestTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorGroupPermissionRequestTest.java @@ -12,7 +12,7 @@ class UpdateApiConnectorGroupPermissionRequestTest { @Test void toCommandMapsAllFields() { var expiresAt = Instant.parse("2030-01-01T00:00:00Z"); - var request = new UpdateApiConnectorGroupPermissionRequest(true, false, true, expiresAt, + var request = new UpdateApiConnectorGroupPermissionRequest(true, false, true, false, expiresAt, List.of("createPet"), List.of("data.token")); var command = request.toCommand(); @@ -27,11 +27,30 @@ void toCommandMapsAllFields() { @Test void toCommandPreservesNulls() { - var command = new UpdateApiConnectorGroupPermissionRequest(false, false, false, null, null, null) + var command = new UpdateApiConnectorGroupPermissionRequest(false, false, false, false, null, null, null) .toCommand(); assertThat(command.expiresAt()).isNull(); assertThat(command.allowedOperations()).isNull(); assertThat(command.restrictedResponseFields()).isNull(); } + + /** + * AF-613 added {@code canOverrideVariables}. It is boxed precisely so a client written before + * that field existed still works — Jackson 3 rejects an absent primitive boolean with a + * 500 rather than defaulting it, which would have broken every existing caller. + */ + @Test + void toCommandDefaultsAnOmittedOverrideFlagToFalse() { + var command = new UpdateApiConnectorGroupPermissionRequest(true, false, true, null, null, null, null).toCommand(); + + assertThat(command.canOverrideVariables()).isFalse(); + } + + @Test + void toCommandCarriesTheOverrideFlagWhenSet() { + var command = new UpdateApiConnectorGroupPermissionRequest(true, false, true, true, null, null, null).toCommand(); + + assertThat(command.canOverrideVariables()).isTrue(); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorPermissionRequestTest.java b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorPermissionRequestTest.java index 115fcf7c..080293c9 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorPermissionRequestTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/apigov/internal/web/UpdateApiConnectorPermissionRequestTest.java @@ -12,7 +12,7 @@ class UpdateApiConnectorPermissionRequestTest { @Test void toCommandMapsAllFields() { var expiresAt = Instant.parse("2030-01-01T00:00:00Z"); - var request = new UpdateApiConnectorPermissionRequest(true, false, true, expiresAt, + var request = new UpdateApiConnectorPermissionRequest(true, false, true, false, expiresAt, List.of("createPet"), List.of("data.token")); var command = request.toCommand(); @@ -27,11 +27,30 @@ void toCommandMapsAllFields() { @Test void toCommandPreservesNulls() { - var command = new UpdateApiConnectorPermissionRequest(false, false, false, null, null, null) + var command = new UpdateApiConnectorPermissionRequest(false, false, false, false, null, null, null) .toCommand(); assertThat(command.expiresAt()).isNull(); assertThat(command.allowedOperations()).isNull(); assertThat(command.restrictedResponseFields()).isNull(); } + + /** + * AF-613 added {@code canOverrideVariables}. It is boxed precisely so a client written before + * that field existed still works — Jackson 3 rejects an absent primitive boolean with a + * 500 rather than defaulting it, which would have broken every existing caller. + */ + @Test + void toCommandDefaultsAnOmittedOverrideFlagToFalse() { + var command = new UpdateApiConnectorPermissionRequest(true, false, true, null, null, null, null).toCommand(); + + assertThat(command.canOverrideVariables()).isFalse(); + } + + @Test + void toCommandCarriesTheOverrideFlagWhenSet() { + var command = new UpdateApiConnectorPermissionRequest(true, false, true, true, null, null, null).toCommand(); + + assertThat(command.canOverrideVariables()).isTrue(); + } } diff --git a/backend/src/test/java/com/bablsoft/accessflow/dashboard/internal/DefaultDashboardServiceTest.java b/backend/src/test/java/com/bablsoft/accessflow/dashboard/internal/DefaultDashboardServiceTest.java index b9692f22..4c0943b8 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/dashboard/internal/DefaultDashboardServiceTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/dashboard/internal/DefaultDashboardServiceTest.java @@ -80,14 +80,14 @@ private ApiRequestView apiRequest() { return new ApiRequestView(UUID.randomUUID(), UUID.randomUUID(), "Conn", USER, "u@test.io", "GetThing", "GET", "/v1/things", false, QueryStatus.PENDING_REVIEW, SubmissionReason.USER_SUBMITTED, "why", UUID.randomUUID(), RiskLevel.LOW, 10, "ok", - com.bablsoft.accessflow.apigov.api.ApiBodyType.RAW, null, null, null, null, null, null, false, + com.bablsoft.accessflow.apigov.api.ApiBodyType.RAW, java.util.Map.of(), null, null, null, null, null, null, false, null, false, null, null, NOW, List.of()); } private PendingApiReview pendingApi() { return new PendingApiReview(UUID.randomUUID(), UUID.randomUUID(), "Conn", UUID.randomUUID(), "POST", "/v1/things", true, "why", UUID.randomUUID(), RiskLevel.HIGH, 80, "summary", - 1, NOW); + 1, 0, NOW); } /** Default no-op stubs for the API-governance collaborators, overridden per test as needed. */ diff --git a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceCrudTest.java b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceCrudTest.java index 4b547f76..57a7d704 100644 --- a/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceCrudTest.java +++ b/backend/src/test/java/com/bablsoft/accessflow/requestgroups/internal/DefaultRequestGroupServiceCrudTest.java @@ -100,7 +100,7 @@ private DatasourceUserPermissionView dsPerm(boolean read, boolean write, boolean } private ApiConnectorPermissionLookupView apiPerm(boolean read, boolean write, boolean bg) { - return new ApiConnectorPermissionLookupView(connectorId, userId, read, write, bg, List.of(), null); + return new ApiConnectorPermissionLookupView(connectorId, userId, read, write, bg, false, List.of(), null); } private RequestGroupItemInput queryInput() { diff --git a/docs/03-data-model.md b/docs/03-data-model.md index dea9de4a..253428b2 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -1700,6 +1700,7 @@ Per-user, per-connector grants — how an admin shares governed connectivity wit | `connector_id` | UUID | FK → `api_connectors` `ON DELETE CASCADE`. Unique with `user_id`. | | `user_id` | UUID | Bare UUID. | | `can_read` / `can_write` / `can_break_glass` | BOOLEAN | | +| `can_override_variables` | BOOLEAN NOT NULL DEFAULT FALSE | AF-613. May supply per-request overrides for the connector's overridable dynamic variables. A capability distinct from submitting; never conferred by a JIT access grant. | | `expires_at` | TIMESTAMPTZ | JIT expiry (nullable). | | `allowed_operations` | TEXT[] | Operation-id subset the user may call (null = all). | | `restricted_response_fields` | TEXT[] | Dot-paths masked in responses for this user (legacy FULL masks; merged with `api_connector_masking_policy` since AF-518). | @@ -1722,6 +1723,7 @@ permission check, response-field masking, text-to-API access, admin visibility) | `connector_id` | UUID | FK → `api_connectors` `ON DELETE CASCADE`. Unique with `group_id`. | | `group_id` | UUID | FK → `user_groups` `ON DELETE CASCADE`. | | `can_read` / `can_write` / `can_break_glass` | BOOLEAN | | +| `can_override_variables` | BOOLEAN NOT NULL DEFAULT FALSE | AF-613. OR-merged with the direct grant like the other flags. | | `expires_at` | TIMESTAMPTZ | JIT expiry (nullable), honoured per grant. | | `allowed_operations` | TEXT[] | Operation-id subset (null = all). | | `restricted_response_fields` | TEXT[] | Dot-paths masked in responses. | @@ -1784,6 +1786,43 @@ COALESCE(operation_id,''), field_ref, classification)` (COALESCE collapses NULL connector-level tags are rejected too). Tagging a field auto-derives a masking policy and raises the apigov AI analyzer's risk for calls to the operation. +## api_connector_variables (AF-613) + +Per-connector **dynamic variables** (migration V121): named expressions evaluated at execution time +and substituted into headers, path, query and body via `{{name}}` placeholders — request signing, +nonces, timestamps, idempotency keys. Resolved after connector auth, so an expression may sign the +finished `Authorization` header. + +| Column | Type | Notes | +|--------|------|-------| +| `id` | UUID PK | | +| `organization_id` | UUID | Bare UUID. | +| `connector_id` | UUID | FK → `api_connectors` `ON DELETE CASCADE`. | +| `name` | TEXT | The placeholder key, referenced as `{{name}}`. `^[A-Za-z][A-Za-z0-9_]{0,63}$` — dot-free, so it can never shadow the `request.` / `var.` namespaces. | +| `kind` | ENUM `api_variable_kind` | `CONSTANT`, `UUID`, `TIMESTAMP`, `EPOCH_MILLIS`, `RANDOM_HEX`, `HASH`, `HMAC`, `ENCODE`. | +| `expression` | TEXT | The input template, itself placeholder-expanded. Required, optional or forbidden per kind. | +| `algorithm` | ENUM `api_variable_algorithm` | `HMAC_SHA256`/`HMAC_SHA512` for `HMAC`; `SHA256`/`MD5` for `HASH`; null otherwise. | +| `encoding` | ENUM `api_variable_encoding` | `HEX` / `BASE64` / `BASE64URL` (unpadded, RFC 7515 shape). | +| `secret_encrypted` | TEXT | AES-256-GCM shared key for `HMAC`. `@JsonIgnore`; never returned in a GET, never logged. | +| `target` | TEXT | Optional auto-injection site: `header:` or `query:`, applied after substitution. No whole-body form. | +| `overridable` | BOOLEAN NOT NULL DEFAULT false | Opts the variable into per-request overrides. Deny by default. | +| `description` | TEXT | Shown to submitters alongside the placeholder. | +| `sort_order` | INT NOT NULL DEFAULT 0 | Evaluation order tie-break. | +| `version` | BIGINT | Optimistic lock. | +| `created_at` / `updated_at` | TIMESTAMPTZ | | + +Unique index `uq_api_connector_variable_name (organization_id, connector_id, name)` — mandatory, or +`{{name}}` would be ambiguous. Index on `(organization_id, connector_id, sort_order)` backs the +per-execution scan already in evaluation order. `CONSTRAINT ck_acv_override_no_secret CHECK (NOT +(overridable AND secret_encrypted IS NOT NULL))` — a secret-bearing variable must never be +overridable, and that rule must survive a manual data fix, so it is belt-and-braces with the +service-layer check. + +Ordering deliberately deviates from the AF-518 child-table idiom (`ORDER BY created_at`). Masking +policies are order-insensitive — they all apply — whereas variable evaluation order is observable, +and `created_at` defaults to `CURRENT_TIMESTAMP`, which is transaction-constant in Postgres, so two +rows inserted in one transaction would tie exactly. `(sort_order, created_at, id)` is a total order. + ## api_requests (AF-500) Governed API calls (migration V101), mirroring `query_requests`. Reuses the shared `query_status` / @@ -1797,6 +1836,7 @@ Governed API calls (migration V101), mirroring `query_requests`. Reuses the shar | `verb` | VARCHAR(16) | HTTP method / GraphQL op / gRPC method. | | `request_path` | TEXT | | | `request_headers` | JSONB | Sanitized header set. | +| `variable_overrides` | JSONB NOT NULL DEFAULT `'{}'` | AF-613. Submitter-supplied values for connector variables marked `overridable`. Persisted rather than transient because a reviewer approves what is stored and submit → review → execute is asynchronous. Immutable after submit. | | `request_body` | TEXT | Raw text, or the base64 of a binary/file body (#517). | | `body_type` | `api_body_type` | How the body is composed (#517, AF-517): `NONE` / `RAW` / `FORM_DATA` / `FORM_URLENCODED` / `BINARY`. Default `RAW`. | | `request_content_type` | TEXT | Content-Type for a `RAW` body (#517). | diff --git a/docs/04-api-spec.md b/docs/04-api-spec.md index d8166bde..d8a4014e 100644 --- a/docs/04-api-spec.md +++ b/docs/04-api-spec.md @@ -4725,11 +4725,43 @@ records the filter, `total_operation_count`, and `excluded_count` for both uploa | `DELETE` | `/api-connectors/{connectorId}/classification-tags/{tagId}` | **Admin.** Remove a classification tag (`204`; keeps the derived masking policy). | | `GET` | `/api-connectors/{connectorId}/classification-tags/derivation-preview` | **Admin.** Preview the aggregated review posture + masking suggestions implied by the connector's tags (suggested, never auto-applied). | +### Connector variables (AF-613) + +Named per-connector expressions evaluated at execution time and substituted into headers, path, query +and body via `{{name}}` placeholders — request signing, nonces, timestamps, idempotency keys. They +resolve **after** connector auth (including a freshly minted OAuth2 token), so an expression may sign +the finished `Authorization` header. Expression evaluation is template substitution plus a fixed +function set only: no scripting engine, no user-supplied code. + +Reference forms inside an expression or at a substitution site: `{{name}}` (lenient — an unknown name +stays literal, so unrelated `{{…}}` templating in a body survives), `{{var.name}}` (strict — an +unknown name is an error), and, inside an expression only, `{{request.method}}`, `{{request.path}}`, +`{{request.query}}`, `{{request.body}}` and `{{request.headers.}}`. Every `request.*` value +describes the request **before** substitution — the motivating vendor scheme signs a body that still +contains its own `{{signature}}` placeholder. Substitution is single-pass: a resolved value is never +re-scanned. + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api-connectors/{connectorId}/variables` | **Admin.** List the connector's variables in evaluation order. Returns `{ content: [...] }`. The stored secret is never returned — only `hasSecret`. | +| `POST` | `/api-connectors/{connectorId}/variables` | **Admin.** Create a variable (`201`): `name` (`^[A-Za-z][A-Za-z0-9_]{0,63}$`, unique per connector), `kind` (`CONSTANT`/`UUID`/`TIMESTAMP`/`EPOCH_MILLIS`/`RANDOM_HEX`/`HASH`/`HMAC`/`ENCODE`), `expression?`, `algorithm?` (`HMAC_SHA256`/`HMAC_SHA512` for `HMAC`; `SHA256`/`MD5` for `HASH`), `encoding?` (`HEX`/`BASE64`/`BASE64URL` — unpadded), `secret?` (write-only, required for `HMAC`, AES-256-GCM at rest), `target?` (`header:` or `query:`), `overridable?`, `description?`, `sortOrder?`. `422 ILLEGAL_API_CONNECTOR_VARIABLE` on a bad name, a field the kind forbids or requires, a malformed or duplicate target, a reference to an unknown variable, or a dependency cycle. | +| `PUT` | `/api-connectors/{connectorId}/variables/{variableId}` | **Admin.** Update a variable. `secret` is tri-state: omit to keep the stored value, send a value to replace it, or send `clearSecret: true` to remove it. A rename is rejected while another variable references the old name. | +| `DELETE` | `/api-connectors/{connectorId}/variables/{variableId}` | **Admin.** Delete a variable (`204`). `422` when another variable still references it. | +| `PUT` | `/api-connectors/{connectorId}/variables/order` | **Admin.** Reassign evaluation order: `variableIds` must list the connector's complete set exactly once (`422` otherwise). Order is observable for the time- and randomness-dependent kinds. | +| `GET` | `/api-connectors/{id}/variables/summary` | Submitter-visible projection — `name`, `kind`, `description`, `overridable` only. Carries no expression, algorithm or secret: a submitter may reference `{{signature}}` but must never learn how it is computed. Same visibility guard as `GET /api-connectors/{id}`. | + +**Per-request overrides.** A variable marked `overridable` may be given a value per request via +`variableOverrides` on the submit endpoint. Supplying any override requires `canOverrideVariables` on +the connector grant. A secret-bearing variable can never be overridable (enforced by the service +*and* a database CHECK constraint), and an override value is inserted as an opaque literal that is +never itself expanded as a template. Overrides are persisted and shown to reviewers, so an approval +covers exactly what will execute. + ### Governed API requests | Method | Path | Description | |--------|------|-------------| -| `POST` | `/api-requests` | Submit a governed API call (`202`): `connectorId`, `operationId?`, `verb`, `requestPath`, `requestHeaders?`, `queryParams?` (#517), `bodyType?` (`NONE`/`RAW`/`FORM_DATA`/`FORM_URLENCODED`/`BINARY`, default `RAW`; #517), `requestContentType?` (for `RAW`), `requestBody?` (raw text or base64 for `BINARY`), `formFields?` (`[{key,type,value,filename,contentType}]` for form bodies; file parts base64), `binaryFilename?`, `justification?`, `scheduledFor?`, `submissionReason?` (`EMERGENCY_ACCESS` = break-glass). The total encoded body is capped by `ACCESSFLOW_APIGOV_MAX_REQUEST_BODY_BYTES` (`422` when exceeded). A W3C `trace_id`/`span_id` is generated. Runs AI → routing → review. | +| `POST` | `/api-requests` | Submit a governed API call (`202`): `connectorId`, `operationId?`, `verb`, `requestPath`, `requestHeaders?`, `queryParams?` (#517), `bodyType?` (`NONE`/`RAW`/`FORM_DATA`/`FORM_URLENCODED`/`BINARY`, default `RAW`; #517), `requestContentType?` (for `RAW`), `requestBody?` (raw text or base64 for `BINARY`), `formFields?` (`[{key,type,value,filename,contentType}]` for form bodies; file parts base64), `binaryFilename?`, `variableOverrides?` (AF-613; per-request values for connector variables marked `overridable` — requires `canOverrideVariables` on the connector grant, max 32 entries, each bounded by `ACCESSFLOW_APIGOV_MAX_VARIABLE_VALUE_BYTES` and rejected if it contains CR/LF/NUL; a name outside the connector's overridable set is `422` with a uniform message so the set cannot be enumerated), `justification?`, `scheduledFor?`, `submissionReason?` (`EMERGENCY_ACCESS` = break-glass). The total encoded body is capped by `ACCESSFLOW_APIGOV_MAX_REQUEST_BODY_BYTES` (`422` when exceeded). A W3C `trace_id`/`span_id` is generated. Runs AI → routing → review. | | `GET` | `/api-requests` | List requests (admins see all; others their own). Paginated. Optional filters: `status`, `connector_id`, `verb`, `submitted_by` (admin-only, #517), `trace_id`, `span_id` (#517), `from`, `to` (ISO-8601 instants; `from` inclusive, `to` exclusive). The response carries `submittedByEmail`, `bodyType`, `traceId`, `spanId`, and `responseContentType`. | | `GET` | `/api-requests/{id}` | Get a request with its (masked) response snapshot + review decisions. Readable by the **submitter, a REVIEWER, or an ADMIN** in the org (parity with query detail / "View all query history"); anyone else gets `404`. | | `GET` | `/api-requests/{id}/response` | Download the full stored (masked, size-capped) response snapshot as an attachment in its captured `Content-Type` (#517). Same access guard as the detail endpoint (submitter / REVIEWER / ADMIN); `409` if no response is stored. | @@ -4759,7 +4791,7 @@ do not create `query_requests` / `api_requests` rows. | Method | Path | Description | |--------|------|-------------| -| `POST` | `/request-groups` | Create a `DRAFT` group (`201`): `name` (3–255, required), `description?`, `continueOnError?` (default false), `items[]` (ordered members, ≥1). Each item: `targetKind` (`QUERY`/`API_CALL`); for `QUERY` → `datasourceId`, `sqlText`, `transactional?`; for `API_CALL` → `connectorId`, `operationId?`, `verb`, `requestPath`, `requestHeaders?`, `queryParams?`, `bodyType?`, `requestContentType?`, `requestBody?`, `formFields?`, `binaryFilename?`. `formFields` items use the same shape as the `/api-requests` submit body (#559): `key`, `type` (`TEXT`/`FILE`), `value` (literal or base64 for `FILE`), `filename?`, `contentType?`. Each member is validated against the submitter's permission for its target — `403` when the submitter can't use a referenced datasource/connector. | +| `POST` | `/request-groups` | Create a `DRAFT` group (`201`): `name` (3–255, required), `description?`, `continueOnError?` (default false), `items[]` (ordered members, ≥1). Each item: `targetKind` (`QUERY`/`API_CALL`); for `QUERY` → `datasourceId`, `sqlText`, `transactional?`; for `API_CALL` → `connectorId`, `operationId?`, `verb`, `requestPath`, `requestHeaders?`, `queryParams?`, `bodyType?`, `requestContentType?`, `requestBody?`, `formFields?`, `binaryFilename?`. A grouped member does **not** accept `variableOverrides` — the connector's dynamic variables (AF-613) still resolve normally at execution, but per-request overrides are out of scope for grouped requests. `formFields` items use the same shape as the `/api-requests` submit body (#559): `key`, `type` (`TEXT`/`FILE`), `value` (literal or base64 for `FILE`), `filename?`, `contentType?`. Each member is validated against the submitter's permission for its target — `403` when the submitter can't use a referenced datasource/connector. | | `GET` | `/request-groups` | List the caller's groups (admins see all org groups). Paginated (`page`, `size`). Optional filter: `status`. | | `GET` | `/request-groups/{id}` | Get a group with its ordered items (per-member status, risk, and result snapshot) + group review decisions. Each item embeds its full **`aiAnalysis`** object (AF-531) when one was persisted — same shape as the query-detail `aiAnalysis`: `summary`, raw `issues`/`optimizations` JSON arrays, `missingIndexesDetected`, `affectsRowEstimate`, `aiProvider`/`aiModel`, token counts, `failed`/`errorMessage`. Both `QUERY` and `API_CALL` members are analyzed and persisted at submission. Each `API_CALL` item also embeds its full saved **request composition** (#559) — `requestHeaders`, `queryParams`, `bodyType`, `requestContentType`, `requestBody`, `formFields` (same `key`/`type` shape as create), `binaryFilename` — so a `DRAFT` can be re-opened for editing without losing the composed request. Note that `FILE`/`BINARY` payloads ride back as the originally uploaded base64. The **list** endpoint does *not* embed `aiAnalysis` or the composition (risk scalars + call shape only). | | `PUT` | `/request-groups/{id}` | **Submitter only.** Replace a `DRAFT` group's editable fields + items (same body as create). `409` when the group is no longer `DRAFT`. | diff --git a/docs/05-backend.md b/docs/05-backend.md index 190bda6a..19bc2ea8 100644 --- a/docs/05-backend.md +++ b/docs/05-backend.md @@ -2092,8 +2092,8 @@ submit (`POST /api/v1/api-requests`) → async rate-limited AI risk scoring (`ai fail-safe) → routing (`api_routing_policies`) + human review (`ApiReviewService`, self-approval forbidden, via `ApiReviewStateMachine`) → guarded execution (`ApiExecutionService`: connector-auth injection, response cap = min(per-connector `max_response_bytes`, system `max-response-bytes` -ceiling), recursive dot-path response masking via `ColumnMasker`, immutable masked snapshot stored in -full for download). Break-glass (`EMERGENCY_ACCESS` + `can_break_glass`), scheduled execution +ceiling), **dynamic-variable resolution + substitution** (AF-613, below), recursive dot-path response +masking via `ColumnMasker`, immutable masked snapshot stored in full for download). Break-glass (`EMERGENCY_ACCESS` + `can_break_glass`), scheduled execution (`ApiRequestRunJob`), review timeout (`ApiRequestTimeoutJob`), and text-to-API (`ApiCallAnalyzer.generateApiCall`, schema connectors only) all mirror the query path. gRPC call **execution** is the one piece not yet wired (REST/SOAP/GraphQL execute over the JDK HTTP client). @@ -2160,6 +2160,64 @@ bounded size/timeout) when `rawContent` is blank — a third ingestion mode alon upload — raising `ApiSchemaFetchException` (422 `API_SCHEMA_FETCH_ERROR`) on failure. The submitter's email is resolved via `core.api.UserQueryService` for the list/detail submitter column. +**Dynamic variables (AF-613).** A connector may declare named variables — rows in +`api_connector_variables` — that are evaluated per request and substituted into header values, the +path, query values and the body via `{{name}}` placeholders. This is what makes vendor contracts +requiring a computed value per call (HMAC request signing, nonces, timestamps, correlation ids, +idempotency keys, digests) governable at all: a submitter cannot hand-compute a signature for a +request a reviewer will approve minutes or hours later. + +*Where it runs.* Inside `ApiExecutionService.executeCall`, between composing the `ApiCallRequest` and +handing it to the executor — deliberately **after** `buildHeaders`, so the evaluation context already +carries the connector defaults, the per-request headers, the trace headers and the auth header the +applier just computed (including a freshly minted OAuth2 bearer). An expression can therefore sign +the finished `Authorization` value, which the motivating vendor scheme requires. The OAuth2 401 +retry rebuilds headers and re-resolves from scratch: a nonce must not be replayed, and a signature +over the stale token would only fail again. `executeInline` (the admin "try it" path) and break-glass +both route through the same seam. + +*The contract.* `DefaultApiConnectorVariableResolutionService` orders the variables topologically +over their `{{var.x}}` references (`ApiVariableGraph`, Kahn's algorithm with the repository's +`(sort_order, created_at, id)` order as a total tie-break, so evaluation order is deterministic and +operator-controlled), renders each `expression` through `ApiVariableTemplate`, and feeds the result +to `ApiVariableEvaluator` — a fixed function set over `CONSTANT` / `UUID` / `TIMESTAMP` / +`EPOCH_MILLIS` / `RANDOM_HEX` / `HASH` / `HMAC` / `ENCODE`, with `HEX` / `BASE64` / `BASE64URL` +(unpadded) encodings. There is no scripting engine and no expression language, deliberately mirroring +the engine plugins' rejection of server-side scripting (`$where`, Painless, CQL UDFs). + +*Two properties worth stating explicitly.* First, every `{{request.*}}` value describes the request +**before** substitution — the vendor scheme signs a body that still contains its own +`{{signature}}` placeholder, and resolving post-substitution would silently produce a digest the +vendor rejects. Second, rendering is **single-pass**: a substituted value is never re-scanned, which +is what keeps a per-request override an opaque literal rather than a path into another variable's +value. + +*What is and is not substituted.* Header **values**, the path, query **values**, `RAW` bodies and +`TEXT` form parts are. Header **names** and query **keys** are not (a variable-named header could not +be meaningfully reviewed); nor is `base_url` (a variable-controlled host is an SSRF pivot); nor are +`BINARY` bodies and `FILE` form parts (base64 — substitution would corrupt rather than template +them). A variable may also carry a `target` of `header:` or `query:`, applied after +substitution, for vendors that want the value in a fixed header rather than at a placeholder. + +*Secrecy.* A resolved value never leaves the call: it is not persisted onto `api_requests`, not +written into the response snapshot, and not logged. The resolver cannot tell a harmless signature +from a `CONSTANT` holding a shared secret, so all of them are treated as sensitive — including +scrubbing them out of any upstream failure message before it reaches the persisted, reviewer-visible +`error_message` (the JDK's `IOException` embeds the full URI, which may carry a `query:`-targeted +signature). Save-time validation rejects cycles, dangling references and bad kind/field combinations +as 422s while the operator is editing, rather than as a failed run after approval. + +*Per-request overrides.* A variable marked `overridable` may be given a value per request +(`api_requests.variable_overrides`, persisted so a reviewer approves exactly what will execute). +Supplying any override needs `can_override_variables` on the connector grant — a capability distinct +from submitting, OR-merged across user and group grants like `can_break_glass`, and never conferred +by a JIT access grant. A secret-bearing variable is never overridable (service check plus a database +CHECK constraint). Names outside the connector's overridable set are rejected with a single uniform +message, so a submitter cannot enumerate which variables hold secrets; values are bounded and +rejected outright if they contain CR, LF or NUL (request splitting). Grouped requests (AF-501) +deliberately do not accept overrides — connector variables still resolve for a grouped member, but +the group-item shape carries no override field. + ## Request chaining & grouping (AF-501) The **`requestgroups/` module** (`com.bablsoft.accessflow.requestgroups`, migration **V106**) lets a diff --git a/docs/06-frontend.md b/docs/06-frontend.md index c6a12dd3..c798e0f8 100644 --- a/docs/06-frontend.md +++ b/docs/06-frontend.md @@ -469,11 +469,11 @@ under `apiGov.*` in every registered locale. | Route | Page | Notes | |-------|------|-------| | `/api-connectors` | `ApiConnectorsListPage` | Admin list of connectors (protocol / auth / active) with Skeleton + EmptyState; "New connector" CTA. | -| `/api-connectors/:id/settings` | `ApiConnectorSettingsPage` | Create/edit form (protocol, base URL, auth method + credential fields, a `default_headers` key-value editor and a `trace_header_mapping` editor (#517), governance toggles incl. AI analysis / text-to-API switches and an **AI-config `Select`** — required when either AI feature is on, mirroring the datasource wizard — plus a **Review-plan `Select`** (AF-579; options from `GET /review-plans`, `allowClear`, same UX as the datasource wizard — clearing it sends `clear_review_plan: true` on save so the assignment is unset, and the hint notes that a connector without a plan defaults to a single approval), schema ingestion with three modes — paste / file upload / `sourceUrl` (#517) — plus a collapsible **import-filter** section (AF-614: exclude/include path globs, exclude verbs, exclude operation-id globs, exclude tags, and an "exclude deprecated" checkbox, all mirroring the backend's 100-entry / 200-character caps) with a **Preview** button (`POST .../schemas/preview`) that reports "Keeps K of N operations" and lists what would be dropped before committing, an "N of M operations imported" result line after upload, and a per-row **Edit filter** modal (`PUT .../schemas/{schemaId}/filter`) that re-edits the filter without re-uploading the document — + parsed-operation explorer, and the per-user "Share with team" permission grants (`can_read`/`can_write`/`can_break_glass`/`expires_at`/`allowed_operations`/`restricted_response_fields`). AntD `Form`; validation parity with the backend DTOs. The "New connector" modal on `/api-connectors` carries the same AI-analysis/text-to-API/AI-config and review-plan fields plus a `default_headers` editor. | -| `/api-editor` | `ApiEditorPage` | Connector picker → the shared **`ApiAuthoringPanel`** (`components/apigov/ApiAuthoringPanel.tsx` + `useApiAuthoring.ts`, #559): searchable+sorted operation `Select` from the parsed catalog auto-filling verb/path (or free-form verb + path) → a Postman-style request composer (`ApiRequestComposer`: Params / Headers / Body tabs; body modes none/raw/x-www-form-urlencoded/form-data/binary with file uploads read to bounded base64; the connector's default headers shown read-only) → AI risk preview (`POST /api-requests/analyze`, `RiskBadge`) and the "describe the call in plain English" text-to-API box (`POST /api-requests/generate`, shown only when the connector has a schema and `text_to_api_enabled`). The page keeps the connector select, optional scheduled-run `DatePicker` (#517), justification, and submit; the group builder's `GroupMemberEditDrawer` mounts the same panel for API steps. | +| `/api-connectors/:id/settings` | `ApiConnectorSettingsPage` | Create/edit form (protocol, base URL, auth method + credential fields, a `default_headers` key-value editor and a `trace_header_mapping` editor (#517), governance toggles incl. AI analysis / text-to-API switches and an **AI-config `Select`** — required when either AI feature is on, mirroring the datasource wizard — plus a **Review-plan `Select`** (AF-579; options from `GET /review-plans`, `allowClear`, same UX as the datasource wizard — clearing it sends `clear_review_plan: true` on save so the assignment is unset, and the hint notes that a connector without a plan defaults to a single approval), schema ingestion with three modes — paste / file upload / `sourceUrl` (#517) — plus a collapsible **import-filter** section (AF-614: exclude/include path globs, exclude verbs, exclude operation-id globs, exclude tags, and an "exclude deprecated" checkbox, all mirroring the backend's 100-entry / 200-character caps) with a **Preview** button (`POST .../schemas/preview`) that reports "Keeps K of N operations" and lists what would be dropped before committing, an "N of M operations imported" result line after upload, and a per-row **Edit filter** modal (`PUT .../schemas/{schemaId}/filter`) that re-edits the filter without re-uploading the document — + parsed-operation explorer, a **Variables** tab (AF-613: `ApiConnectorVariablesTab` — per-connector dynamic variables for request signing / nonces / timestamps, with a kind-driven form that shows only the fields the kind uses, an MD5 warning, up/down reorder because evaluation order is observable, a write-only secret field that is never round-tripped, and an `overridable` switch disabled for secret-bearing kinds to mirror the server rule), and the per-user "Share with team" permission grants (`can_read`/`can_write`/`can_break_glass`/`can_override_variables`/`expires_at`/`allowed_operations`/`restricted_response_fields`). AntD `Form`; validation parity with the backend DTOs. The "New connector" modal on `/api-connectors` carries the same AI-analysis/text-to-API/AI-config and review-plan fields plus a `default_headers` editor. | +| `/api-editor` | `ApiEditorPage` | Connector picker → the shared **`ApiAuthoringPanel`** (`components/apigov/ApiAuthoringPanel.tsx` + `useApiAuthoring.ts`, #559): searchable+sorted operation `Select` from the parsed catalog auto-filling verb/path (or free-form verb + path) → a Postman-style request composer (`ApiRequestComposer`: Params / Headers / Body tabs — plus a **Variables** tab (AF-613) shown only when the connector declares dynamic variables, listing the available `{{name}}` placeholders and offering per-request overrides for the ones marked overridable; body modes none/raw/x-www-form-urlencoded/form-data/binary with file uploads read to bounded base64; the connector's default headers shown read-only) → AI risk preview (`POST /api-requests/analyze`, `RiskBadge`) and the "describe the call in plain English" text-to-API box (`POST /api-requests/generate`, shown only when the connector has a schema and `text_to_api_enabled`). The page keeps the connector select, optional scheduled-run `DatePicker` (#517), justification, and submit; the group builder's `GroupMemberEditDrawer` mounts the same panel for API steps — without the Variables tab, since the group-item wire shape carries no overrides (AF-613). | | `/api-requests` | `ApiRequestsListPage` | API requests aligned with the Query History page: a filter bar (search, status, connector, verb, risk, submitter, trace id, span id, date range; #517), a **Submitter** column, `StatusPill`/`RiskPill`, server-side pagination + filtering (`status`/`connector_id`/`verb`/`trace_id`/`span_id`/`from`/`to`; risk + free-text incl. submitter email filtered client-side), and row click → detail. | -| `/api-requests/:id` | `ApiRequestDetailPage` | Responsive card layout: status (`StatusPill`) + AI risk (`RiskPill`), connection/response metadata in a reflowing `Descriptions` (incl. submitter, trace id, span id; #517), justification / AI summary / error cards, the size-capped field-masked response snapshot with a **Download full response** button (`GET /api-requests/{id}/response`, #517), and the review-decisions table. A reviewer/admin viewing a `PENDING_REVIEW` request they didn't submit gets inline **Approve / Reject** actions via a shared comment modal (#567, self-approval blocked server-side), mirroring `ApiReviewQueuePage` so the request is actionable from where the reviewer lands. | -| `/api-reviews` | `ApiReviewQueuePage` | Reviewer/admin queue of API requests awaiting review with the same filter bar (search, connector, verb, risk) + pagination; approve/reject via a comment modal (self-approval blocked server-side). | +| `/api-requests/:id` | `ApiRequestDetailPage` | Responsive card layout: status (`StatusPill`) + AI risk (`RiskPill`), connection/response metadata in a reflowing `Descriptions` (incl. submitter, trace id, span id; #517), justification / **variable-overrides** (AF-613; the signing inputs the submitter supplied, never the resolved outputs) / AI summary / error cards, the size-capped field-masked response snapshot with a **Download full response** button (`GET /api-requests/{id}/response`, #517), and the review-decisions table. A reviewer/admin viewing a `PENDING_REVIEW` request they didn't submit gets inline **Approve / Reject** actions via a shared comment modal (#567, self-approval blocked server-side), mirroring `ApiReviewQueuePage` so the request is actionable from where the reviewer lands. | +| `/api-reviews` | `ApiReviewQueuePage` | Reviewer/admin queue of API requests awaiting review with the same filter bar (search, connector, verb, risk) + pagination, plus an **Overrides** badge column (AF-613) flagging requests that override connector variables; approve/reject via a comment modal (self-approval blocked server-side). | Navigation entries are added to `components/common/Sidebar.tsx` (Data group: connectors; Workflow group: API editor / requests / reviews), role-gated like the rest of the nav. diff --git a/docs/07-security.md b/docs/07-security.md index 4192377b..33609338 100644 --- a/docs/07-security.md +++ b/docs/07-security.md @@ -682,6 +682,38 @@ posture as the query proxy: - **Break-glass parity.** Emergency access requires a per-user/per-connector `can_break_glass` grant (required for everyone, including admins), executes immediately through all guards, writes a prominent `API_REQUEST_BREAK_GLASS_EXECUTED` audit row, and opens a mandatory retro-review. +- **Dynamic-variable secrets (AF-613).** A connector variable of kind `HMAC` stores its shared key in + `api_connector_variables.secret_encrypted`, AES-256-GCM encrypted via `CredentialEncryptionService` + under the same rules as `auth_credentials_encrypted`: `@JsonIgnore`, never returned by a GET (the + view exposes only `has_secret`), never logged, decrypted only inside the resolver at call time. +- **Resolved values are never persisted or logged.** The resolver cannot distinguish a signature + (harmless) from a `CONSTANT` holding a shared secret (not harmless), so every resolved value is + treated as sensitive: none reach `api_requests`, the response snapshot, or the logs. They are also + scrubbed out of any upstream failure message before it lands in the persisted, reviewer-visible + `error_message` — the JDK's `IOException` embeds the full URI, which may carry a `query:`-targeted + signature. +- **No scripting.** Variable evaluation is template substitution plus a fixed function set only — no + expression language, no `eval`, no user-supplied code — deliberately mirroring the engine plugins' + rejection of server-side scripting (`$where`, Painless, CQL UDFs). +- **Single-pass substitution.** A resolved value is never re-scanned for further placeholders. This + is the containment property behind per-request overrides: an override of `{{signingKey}}` stays + eleven literal characters and can never expand into that variable's value. +- **CRLF rejection.** No resolved value — computed or submitter-supplied — may contain CR, LF or NUL. + Any of them landing in a header is request splitting, and an override on a `header:`-targeted + variable is the natural delivery mechanism. Rejected both at submit time (immediate 422) and in the + resolver. +- **Overrides are deny-by-default.** Supplying a per-request override needs `can_override_variables` + on the connector grant, a capability distinct from submitting and never conferred by a JIT access + grant. A secret-bearing variable can never be marked overridable — enforced by the admin service + *and* a database CHECK constraint, so the rule survives a manual data fix. A name outside the + connector's overridable set is rejected with a single uniform message regardless of whether the + variable is unknown, not overridable, or secret-bearing, so the set cannot be enumerated. Overrides + are persisted and shown to reviewers, so an approval covers exactly what will execute; the resolved + *outputs* (nonce, signature) are never surfaced. +- **Accepted gap: config TOCTOU.** A reviewer approves, an admin then edits the connector's + variables, and the scheduled run executes against the new config. The identical gap already exists + for `default_headers`, masking policies and auth credentials, so it is documented rather than fixed + here alone; `API_CONNECTOR_VARIABLE_UPDATED` audit rows make the change traceable. --- diff --git a/docs/09-deployment.md b/docs/09-deployment.md index c15d7193..a1f8de46 100644 --- a/docs/09-deployment.md +++ b/docs/09-deployment.md @@ -1043,6 +1043,7 @@ Deployment-wide tuning for the `ai` module's `BehaviorAnomalyDetectionJob`, whic | `ACCESSFLOW_APIGOV_REVIEW_TIMEOUT` | Optional | `PT24H` | ISO-8601 duration. How long an API request may await review before `ApiRequestTimeoutJob` marks it `TIMED_OUT`. | | `ACCESSFLOW_APIGOV_MAX_REQUEST_BODY_BYTES` | Optional | `5242880` | Cap on the total encoded size of a submitted API request body (#517) — raw text, x-www-form-urlencoded, or the base64-decoded size of form-data / binary file parts. Files ride inline as bounded base64 (no object storage). Exceeding it rejects the submission with HTTP 422. | | `ACCESSFLOW_APIGOV_MAX_RESPONSE_BYTES` | Optional | `10485760` | System-wide hard ceiling (#521) on a stored — and therefore downloadable — API response body; the absolute backstop above any per-connector `max_response_bytes` (effective cap = min of the two). The body lives in a Postgres `text` column and is read fully into JVM memory during the call. | +| `ACCESSFLOW_APIGOV_MAX_VARIABLE_VALUE_BYTES` | Optional | `8192` | Cap on a single resolved connector dynamic-variable value (AF-613) — request signatures, nonces, timestamps. A digest is tens of bytes, so a larger value means a runaway expression or an abusive per-request override. Exceeding it fails the execution; a submitted override over the cap is rejected with 422 at submit time. | | `ACCESSFLOW_APIGOV_RESPONSE_PREVIEW_BYTES` | Optional | `65536` | How much of the stored response snapshot is embedded inline in the API-request detail view (#521); beyond it the full body is fetched via `GET /api-requests/{id}/response`. | | `ACCESSFLOW_LIFECYCLE_POLICY_SCAN_INTERVAL` | Optional | `PT1H` | ISO-8601 duration. Cadence at which `RetentionPolicyScanJob` (the `lifecycle` module, AF-499) scans enabled retention policies and stages eligible `lifecycle_runs`. ShedLock-guarded. | | `ACCESSFLOW_LIFECYCLE_POLICY_EXECUTION_INTERVAL` | Optional | `PT5M` | ISO-8601 duration. Cadence at which `RetentionPolicyExecutionJob` (the `lifecycle` module, AF-519) drains STAGED `RETENTION_POLICY` runs and applies the action through the proxy. ShedLock-guarded. | diff --git a/docs/12-roadmap.md b/docs/12-roadmap.md index d9f48d25..88d66747 100644 --- a/docs/12-roadmap.md +++ b/docs/12-roadmap.md @@ -167,6 +167,7 @@ - **Personalized dashboard & query insights** — a self-scoped home surfacing pending approvals, my-query trends, the AI-suggestion backlog, and anomaly alerts, plus an opt-in weekly digest / export (AF-498) - **Group-based access grants** — grant read / write / DDL / break-glass on datasources and API connectors to a whole user group (inherited by every member), with parity-matched per-member authoring in request groups (AF-530, AF-531) - **Access recertification campaigns** — recurring attestation campaigns over the existing access model: an admin schedules an org- or datasource-scoped campaign that **snapshots** current standing `datasource_user_permissions` grants into per-grant items at open time and notifies the eligible reviewers (multi-channel + a `attestation.campaign_opened` WebSocket event). Reviewers certify or revoke each item from a worklist that reuses the review-queue patterns (eligibility derived from datasource reviewers, **self-review blocked**, bulk-certify); a `REVOKE` routes through the existing permission-revoke service. Two clustered-safe scheduled jobs open campaigns on their cadence and close them at the due date, applying a configurable default (`KEEP`/`REVOKE`) to anything still pending. A completed campaign exports as CSV **evidence** (who reviewed what, decisions, timestamps) for SOC2 / ISO 27001 audits, and every transition lands in the tamper-evident audit log (`ATTESTATION_CAMPAIGN_OPENED/CLOSED`, `ATTESTATION_ITEM_CERTIFIED/REVOKED`) (AF-384) +- **Dynamic variables for API connectors** — named per-connector expressions evaluated at execution time and substituted into headers, path, query and body via `{{name}}` placeholders, making vendor contracts that require a value computed per call governable: HMAC request signing, nonces, timestamps, correlation ids, idempotency keys, digests. Variables resolve **after** connector auth (including a freshly minted OAuth2 token), so an expression can sign the finished `Authorization` header — and `{{request.*}}` describes the request *before* substitution, so a body being signed still carries its own placeholder, exactly as the motivating vendor spec requires. Eight kinds (`CONSTANT`/`UUID`/`TIMESTAMP`/`EPOCH_MILLIS`/`RANDOM_HEX`/`HASH`/`HMAC`/`ENCODE`) over hex / base64 / base64url, resolved in topological order with cycles and dangling references rejected at save time. Evaluation is template substitution plus a fixed function set only — no scripting engine — and resolved values are never persisted, snapshotted or logged. Submitters holding a per-connector `can_override_variables` grant may supply per-request overrides for variables explicitly marked overridable (never a secret-bearing one), persisted and shown to reviewers so an approval covers exactly what will execute (AF-613) - **Masking & data classification for API connectors** — brings the datasource governance model (dynamic masking AF-381 + classification tagging AF-447) to the API Access Governance module (AF-500), adapted to non-tabular API responses. Connector-level masking policies target a response field four ways — a schema-bound field, a JSON path, an XML/XPath, or a regex — each with a masking strategy and role/group/user reveal scoping; `ApiResponseMasker` applies them (reusing `ColumnMasker`) before the response snapshot is stored, so the raw value never persists, and applied policy ids land in the execution audit. Data-classification tags (PII/PCI/PHI/GDPR/FINANCIAL/SENSITIVE) auto-derive a masking policy and raise the apigov AI analyzer's risk for calls to a classified operation, with a derivation preview (AF-518) --- diff --git a/docs/17-api-governance.md b/docs/17-api-governance.md index 27421dee..9d896b75 100644 --- a/docs/17-api-governance.md +++ b/docs/17-api-governance.md @@ -231,11 +231,71 @@ posture + masking suggestions (suggested, never auto-applied). --- +## 6. Dynamic variables (AF-613) + +A connector may declare named variables — rows in `api_connector_variables` — evaluated per request +and substituted into header values, the path, query values and the body via `{{name}}` placeholders. +This is what makes vendor contracts requiring a computed value per call governable: request signing +(HMAC), nonces, timestamps, correlation ids, idempotency keys, digests. A submitter cannot +hand-compute a signature for a request a reviewer will approve hours later, and a timestamp would be +stale by execution time. + +**Kinds.** `CONSTANT`, `UUID`, `TIMESTAMP` (ISO-8601 or a `DateTimeFormatter` pattern at UTC), +`EPOCH_MILLIS`, `RANDOM_HEX` (1–256 secure-random bytes), `HASH` (SHA-256 / MD5), `HMAC` +(HMAC-SHA256 / HMAC-SHA512 keyed with an encrypted shared secret), `ENCODE`. Encodings: `HEX` +(lowercase), `BASE64`, `BASE64URL` (unpadded, the RFC 7515 shape vendors specify). + +**Evaluation context.** Inside an expression: `{{request.method}}`, `{{request.path}}`, +`{{request.query}}` (canonical — keys sorted and percent-encoded, so a signature over it +reproduces), `{{request.body}}`, `{{request.headers.}}` (case-insensitive), and `{{var.x}}` for +another variable. **Every `request.*` value describes the request before substitution** — the +motivating vendor scheme signs a body that still contains its own `{{signature}}` placeholder, and +resolving post-substitution would compute a digest the vendor rejects. + +**Pipeline order.** Auth headers resolved (including a freshly minted OAuth2 token) → variables +evaluated in dependency order → substitution → send. The seam sits in +`ApiExecutionService.executeCall`, after `buildHeaders`, so an expression can sign the finished +`Authorization` value. The OAuth2 401 retry re-resolves from scratch: a nonce must not be replayed +and a signature over the stale token would only fail again. + +**Ordering and cycles.** References form a DAG; `ApiVariableGraph` topologically sorts it with the +repository's `(sort_order, created_at, id)` order as a total tie-break, so independent variables +evaluate in a stable, operator-controlled sequence. Cycles and dangling `{{var.x}}` references are +**configuration errors caught at save time** (422), not runtime failures — the resolver re-runs the +sort defensively but should never trip it. + +**The motivating example, in the model.** The submitter's body carries `"HMAC": "{{signature}}"`; +`signature` is `kind=HMAC`, `algorithm=HMAC_SHA256`, `encoding=HEX`, with the shared key stored +encrypted and `expression={{request.headers.Authorization}}{{request.body}}`. At resolution time +`{{request.body}}` still contains the literal placeholder, exactly as the vendor's step 2 requires; +the executor substitutes the digest back into the body and sends. + +**Targets.** A variable may instead carry `target: header:` or `query:`, applied after +substitution, for vendors that want the value in a fixed header. There is deliberately no whole-body +target — replacing an entire body with one value is never what an operator means, and partial-body +injection needs a JSON pointer. + +**Per-request overrides.** A variable marked `overridable` may be given a value per request. This +needs `can_override_variables` on the connector grant; a secret-bearing variable can never be +overridable (service check plus a database CHECK constraint); an override is an opaque literal that +is never itself expanded as a template, so it cannot become a path into another variable's value. +Dependents still recompute over the overridden value. Overrides are persisted and shown to reviewers, +so an approval covers exactly what will execute. Grouped requests (AF-501) do not accept overrides. + +**Not a scripting engine.** Evaluation is template substitution plus this fixed function set only — +no expression language, no `eval`, no user-supplied code — mirroring the engine plugins' rejection of +server-side scripting. See [docs/07-security.md](07-security.md) for the full secret-handling, +redaction and CRLF rules. + +--- + ## Audit & notifications Every connector/schema/permission/request action writes a tamper-evident `audit_log` row via `audit.api.AuditLogService`: `API_CONNECTOR_CREATED`/`_UPDATED`/`_DELETED`, `API_SCHEMA_UPLOADED`/ -`_DELETED`, `API_PERMISSION_GRANTED`/`_REVOKED`, +`_DELETED`, `API_PERMISSION_GRANTED`/`_REVOKED`, `API_CONNECTOR_VARIABLE_CREATED`/`_UPDATED`/ +`_DELETED`/`API_CONNECTOR_VARIABLES_REORDERED` (AF-613; metadata carries the variable name, kind and +flags — never the expression or the secret), `API_CONNECTOR_MASKING_POLICY_CREATED`/`_UPDATED`/`_DELETED`, `API_CONNECTOR_CLASSIFICATION_TAG_ADDED`/`_REMOVED` (resource `API_CONNECTOR`), and `API_REQUEST_SUBMITTED`/`_APPROVED`/`_REJECTED`/`_EXECUTED`/`_CANCELLED`/`_BREAK_GLASS_EXECUTED` diff --git a/e2e/tests/api-connector-variables.spec.ts b/e2e/tests/api-connector-variables.spec.ts new file mode 100644 index 00000000..0db76fd3 --- /dev/null +++ b/e2e/tests/api-connector-variables.spec.ts @@ -0,0 +1,105 @@ +import { expect, test, type Page } from '@playwright/test'; + +// AF-613: dynamic variables for API connectors — an admin creates a connector, declares an +// HMAC signing variable and an overridable nonce on the Variables tab, reorders them, and deletes +// one. The evaluate-and-sign path (including the vendor HMAC-over-auth-header-plus-body scheme) is +// covered by the backend integration test (ApiConnectorVariablesIntegrationTest), since it needs a +// live upstream API. Seeded admin comes from the bootstrap module. + +const ADMIN_EMAIL = 'e2e@accessflow.test'; +const ADMIN_PASSWORD = 'E2ePassword!123'; + +const CONNECTOR_NAME = `Variables ${Date.now()}`; + +async function loginViaUi(page: Page, email: string, password: string): Promise { + await page.goto('/login'); + await page.locator('#login-email').fill(email); + await page.locator('#login-password').fill(password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/dashboard', { timeout: 15_000 }); +} + +test('admin configures connector dynamic variables', async ({ page }) => { + await loginViaUi(page, ADMIN_EMAIL, ADMIN_PASSWORD); + + // Create a connector. + await page.goto('/api-connectors'); + await page.waitForResponse( + (r) => r.request().method() === 'GET' && r.url().includes('/api/v1/api-connectors') && r.ok(), + ); + await page.getByRole('button', { name: /New connector/i }).click(); + await page.getByLabel('Name', { exact: true }).fill(CONNECTOR_NAME); + await page.getByLabel('Base URL').fill('https://variables.example.com'); + await page.getByLabel('AI config').click(); + await page.getByTitle(/e2e-mock-openai/).click(); + const createResponse = page.waitForResponse( + (r) => r.request().method() === 'POST' && r.url().endsWith('/api/v1/api-connectors') && r.ok(), + ); + await page.getByRole('button', { name: 'Save' }).click(); + await createResponse; + await page.waitForURL('**/api-connectors/*/settings', { timeout: 15_000 }); + + // Ant Design keeps inactive tab panels mounted (display:none), so scope content assertions to the + // active panel or a bare getByText can match a hidden duplicate. + const activePanel = page.locator('.ant-tabs-tabpane-active'); + + await page.getByRole('tab', { name: 'Variables' }).click(); + await expect(activePanel.getByText(/No variables yet/i)).toBeVisible(); + + // An HMAC signature over the resolved Authorization header plus the body — the motivating vendor + // scheme. Selecting the HMAC kind reveals the algorithm and secret fields. + // + // Every form interaction is scoped to the dialog: the Config tab stays mounted (display:none) and + // has its own `Name` field, so a bare getByLabel('Name') resolves to that hidden input instead. + await page.getByRole('button', { name: 'Add variable' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByLabel('Name', { exact: true }).fill('signature'); + await dialog.getByLabel('Kind', { exact: true }).click(); + await page.getByTitle('HMAC signature', { exact: true }).click(); + await dialog.getByLabel('Expression').fill('{{request.headers.Authorization}}{{request.body}}'); + await dialog.getByLabel('Algorithm').click(); + await page.getByTitle('HMAC-SHA256', { exact: true }).click(); + await dialog.getByLabel('Shared secret').fill('e2e-shared-key'); + const signatureCreate = page.waitForResponse( + (r) => r.request().method() === 'POST' && r.url().includes('/variables') && r.ok(), + ); + await dialog.getByRole('button', { name: 'Save' }).click(); + await signatureCreate; + await expect(activePanel.getByText('{{signature}}')).toBeVisible(); + // The stored secret is never returned — the row only reports that one exists. + await expect(activePanel.getByText('Secret').first()).toBeVisible(); + + // A random nonce the submitter may override per request. + await page.getByRole('button', { name: 'Add variable' }).click(); + const nonceDialog = page.getByRole('dialog'); + await nonceDialog.getByLabel('Name', { exact: true }).fill('nonce'); + await nonceDialog.getByLabel('Kind', { exact: true }).click(); + await page.getByTitle('Random bytes', { exact: true }).click(); + await nonceDialog.getByRole('switch').click(); + const nonceCreate = page.waitForResponse( + (r) => r.request().method() === 'POST' && r.url().includes('/variables') && r.ok(), + ); + await nonceDialog.getByRole('button', { name: 'Save' }).click(); + await nonceCreate; + await expect(activePanel.getByText('{{nonce}}')).toBeVisible(); + await expect(activePanel.getByText('Overridable')).toBeVisible(); + + // Evaluation order is observable, so it is operator-controlled: move the nonce ahead of the + // signature that will eventually reference it. + const reorder = page.waitForResponse( + (r) => r.request().method() === 'PUT' && r.url().includes('/variables/order') && r.ok(), + ); + await activePanel.getByRole('button', { name: 'Move earlier' }).last().click(); + await reorder; + // AntD renders a hidden `ant-table-measure-row` as the first tbody row; match real data rows. + await expect(activePanel.locator('tbody tr[data-row-key]').first()).toContainText('{{nonce}}'); + + // Deleting an unreferenced variable succeeds. + const remove = page.waitForResponse( + (r) => r.request().method() === 'DELETE' && r.url().includes('/variables/') && r.ok(), + ); + await activePanel.getByRole('button', { name: 'Delete' }).first().click(); + await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click(); + await remove; + await expect(activePanel.getByText('{{nonce}}')).toBeHidden(); +}); diff --git a/frontend/src/api/apiConnectorVariables.test.ts b/frontend/src/api/apiConnectorVariables.test.ts new file mode 100644 index 00000000..20b59bd3 --- /dev/null +++ b/frontend/src/api/apiConnectorVariables.test.ts @@ -0,0 +1,96 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + apiConnectorVariableKeys, + createApiConnectorVariable, + deleteApiConnectorVariable, + listApiConnectorVariableSummaries, + listApiConnectorVariables, + reorderApiConnectorVariables, + updateApiConnectorVariable, +} from './apiConnectorVariables'; +import { apiClient } from './client'; + +vi.mock('./client', () => ({ + apiClient: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +const connectorId = 'c-1'; +const base = `/api/v1/api-connectors/${connectorId}/variables`; + +describe('apiConnectorVariables', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('builds hierarchical, connector-scoped query keys', () => { + expect(apiConnectorVariableKeys.all).toEqual(['api-connector-variables']); + expect(apiConnectorVariableKeys.list(connectorId)).toEqual([ + 'api-connector-variables', + 'list', + connectorId, + ]); + expect(apiConnectorVariableKeys.summary(connectorId)).toEqual([ + 'api-connector-variables', + 'summary', + connectorId, + ]); + }); + + it('unwraps the list envelope', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { content: [{ id: 'v-1' }] } }); + + await expect(listApiConnectorVariables(connectorId)).resolves.toEqual([{ id: 'v-1' }]); + expect(vi.mocked(apiClient.get).mock.calls[0]?.[0]).toBe(base); + }); + + it('reads summaries from the submitter-safe endpoint', async () => { + vi.mocked(apiClient.get).mockResolvedValue({ data: { content: [{ name: 'nonce' }] } }); + + await expect(listApiConnectorVariableSummaries(connectorId)).resolves.toEqual([ + { name: 'nonce' }, + ]); + expect(vi.mocked(apiClient.get).mock.calls[0]?.[0]).toBe(`${base}/summary`); + }); + + it('posts the create payload', async () => { + vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 'v-1' } }); + const input = { name: 'sig', kind: 'HMAC' as const, secret: 'k' }; + + await createApiConnectorVariable(connectorId, input); + + expect(vi.mocked(apiClient.post).mock.calls[0]?.[0]).toBe(base); + expect(vi.mocked(apiClient.post).mock.calls[0]?.[1]).toEqual(input); + }); + + it('puts the update payload at the variable path', async () => { + vi.mocked(apiClient.put).mockResolvedValue({ data: { id: 'v-1' } }); + + await updateApiConnectorVariable(connectorId, 'v-1', { name: 'sig', kind: 'CONSTANT' }); + + expect(vi.mocked(apiClient.put).mock.calls[0]?.[0]).toBe(`${base}/v-1`); + }); + + it('deletes by id', async () => { + vi.mocked(apiClient.delete).mockResolvedValue({ data: undefined }); + + await deleteApiConnectorVariable(connectorId, 'v-1'); + + expect(vi.mocked(apiClient.delete).mock.calls[0]?.[0]).toBe(`${base}/v-1`); + }); + + it('sends the reorder list under variable_ids and unwraps the response', async () => { + vi.mocked(apiClient.put).mockResolvedValue({ data: { content: [{ id: 'b' }, { id: 'a' }] } }); + + await expect(reorderApiConnectorVariables(connectorId, ['b', 'a'])).resolves.toEqual([ + { id: 'b' }, + { id: 'a' }, + ]); + expect(vi.mocked(apiClient.put).mock.calls[0]?.[0]).toBe(`${base}/order`); + expect(vi.mocked(apiClient.put).mock.calls[0]?.[1]).toEqual({ variable_ids: ['b', 'a'] }); + }); +}); diff --git a/frontend/src/api/apiConnectorVariables.ts b/frontend/src/api/apiConnectorVariables.ts new file mode 100644 index 00000000..7d28e3bc --- /dev/null +++ b/frontend/src/api/apiConnectorVariables.ts @@ -0,0 +1,74 @@ +import { apiClient } from './client'; +import type { + ApiConnectorVariable, + ApiConnectorVariableSummary, + CreateApiConnectorVariableInput, + UpdateApiConnectorVariableInput, +} from '@/types/api'; + +const base = (connectorId: string) => `/api/v1/api-connectors/${connectorId}/variables`; + +export const apiConnectorVariableKeys = { + all: ['api-connector-variables'] as const, + list: (connectorId: string) => ['api-connector-variables', 'list', connectorId] as const, + summary: (connectorId: string) => ['api-connector-variables', 'summary', connectorId] as const, +}; + +export async function listApiConnectorVariables( + connectorId: string, +): Promise { + const { data } = await apiClient.get<{ content: ApiConnectorVariable[] }>(base(connectorId)); + return data.content; +} + +/** + * The submitter-safe projection, used by the request composer. Unlike the admin list it needs no + * connector-manage permission and carries no expression, algorithm or secret. + */ +export async function listApiConnectorVariableSummaries( + connectorId: string, +): Promise { + const { data } = await apiClient.get<{ content: ApiConnectorVariableSummary[] }>( + `/api/v1/api-connectors/${connectorId}/variables/summary`, + ); + return data.content; +} + +export async function createApiConnectorVariable( + connectorId: string, + input: CreateApiConnectorVariableInput, +): Promise { + const { data } = await apiClient.post(base(connectorId), input); + return data; +} + +export async function updateApiConnectorVariable( + connectorId: string, + variableId: string, + input: UpdateApiConnectorVariableInput, +): Promise { + const { data } = await apiClient.put( + `${base(connectorId)}/${variableId}`, + input, + ); + return data; +} + +export async function deleteApiConnectorVariable( + connectorId: string, + variableId: string, +): Promise { + await apiClient.delete(`${base(connectorId)}/${variableId}`); +} + +/** The body must list every variable on the connector exactly once, in evaluation order. */ +export async function reorderApiConnectorVariables( + connectorId: string, + variableIds: string[], +): Promise { + const { data } = await apiClient.put<{ content: ApiConnectorVariable[] }>( + `${base(connectorId)}/order`, + { variable_ids: variableIds }, + ); + return data.content; +} diff --git a/frontend/src/api/apiConnectors.test.ts b/frontend/src/api/apiConnectors.test.ts index 37fa5f20..78b2df88 100644 --- a/frontend/src/api/apiConnectors.test.ts +++ b/frontend/src/api/apiConnectors.test.ts @@ -33,6 +33,7 @@ const permissionFixture = { can_read: true, can_write: false, can_break_glass: false, + can_override_variables: false, expires_at: null, allowed_operations: [], restricted_response_fields: [], @@ -152,6 +153,7 @@ describe('api/apiConnectors', () => { can_read: true, can_write: false, can_break_glass: false, + can_override_variables: false, expires_at: null, allowed_operations: [], restricted_response_fields: [], @@ -166,6 +168,7 @@ describe('api/apiConnectors', () => { can_read: false, can_write: true, can_break_glass: true, + can_override_variables: false, expires_at: '2030-01-01T00:00:00.000Z', allowed_operations: ['createPet'], restricted_response_fields: ['data.ssn'], @@ -195,6 +198,7 @@ describe('api/apiConnectors', () => { can_read: true, can_write: false, can_break_glass: false, + can_override_variables: false, }; await api.grantApiConnectorGroupPermission('c-1', input); expect(post).toHaveBeenCalledWith('/api/v1/api-connectors/c-1/permissions/groups', input); @@ -206,6 +210,7 @@ describe('api/apiConnectors', () => { can_read: false, can_write: true, can_break_glass: true, + can_override_variables: false, expires_at: '2030-01-01T00:00:00.000Z', allowed_operations: ['listPets'], restricted_response_fields: ['data.ssn'], diff --git a/frontend/src/components/apigov/ApiAuthoringPanel.tsx b/frontend/src/components/apigov/ApiAuthoringPanel.tsx index 735376ee..48023c6f 100644 --- a/frontend/src/components/apigov/ApiAuthoringPanel.tsx +++ b/frontend/src/components/apigov/ApiAuthoringPanel.tsx @@ -3,7 +3,7 @@ import { App, Button, Card, Input, Select, Space, Tag } from 'antd'; import { useTranslation } from 'react-i18next'; import { ApiRequestComposer } from '@/components/apigov/ApiRequestComposer'; import { riskLevelLabel } from '@/utils/enumLabels'; -import type { ApiConnector } from '@/types/api'; +import type { ApiConnector, ApiConnectorVariableSummary } from '@/types/api'; import type { ApiAuthoring, ApiAuthoringValue } from './useApiAuthoring'; interface ApiAuthoringPanelProps { @@ -17,6 +17,12 @@ interface ApiAuthoringPanelProps { header?: ReactNode; /** Rendered under the composer — the page puts schedule/justification/actions here. */ footer?: ReactNode; + /** + * The connector's dynamic variables (AF-613). Drives the composer's Variables tab. Grouped + * request members deliberately pass nothing: the group-item wire shape carries no + * `variable_overrides`, so offering the input there would silently drop it. + */ + connectorVariables?: ApiConnectorVariableSummary[]; } /** @@ -33,6 +39,7 @@ export function ApiAuthoringPanel({ layout = 'page', header, footer, + connectorVariables, }: ApiAuthoringPanelProps) { const { t } = useTranslation(); const { message } = App.useApp(); @@ -136,6 +143,7 @@ export function ApiAuthoringPanel({ onChange={(composition) => onChange({ ...value, composition })} defaultHeaders={connector?.default_headers ?? {}} onTooLarge={() => message.error(t('apiGov.editor.fileTooLarge', { maxMb: 5 }))} + connectorVariables={connectorVariables} /> {footer} diff --git a/frontend/src/components/apigov/ApiConnectorPermissionsTab.test.tsx b/frontend/src/components/apigov/ApiConnectorPermissionsTab.test.tsx index bf6c9f3c..bd8f015f 100644 --- a/frontend/src/components/apigov/ApiConnectorPermissionsTab.test.tsx +++ b/frontend/src/components/apigov/ApiConnectorPermissionsTab.test.tsx @@ -74,6 +74,7 @@ const permission: ApiConnectorPermission = { can_read: true, can_write: false, can_break_glass: false, + can_override_variables: false, expires_at: null, allowed_operations: [], restricted_response_fields: [], @@ -128,6 +129,7 @@ const groupPermission: ApiConnectorGroupPermission = { can_read: true, can_write: false, can_break_glass: false, + can_override_variables: false, expires_at: null, allowed_operations: [], restricted_response_fields: [], diff --git a/frontend/src/components/apigov/ApiConnectorPermissionsTab.tsx b/frontend/src/components/apigov/ApiConnectorPermissionsTab.tsx index e2525360..77d039d3 100644 --- a/frontend/src/components/apigov/ApiConnectorPermissionsTab.tsx +++ b/frontend/src/components/apigov/ApiConnectorPermissionsTab.tsx @@ -33,6 +33,7 @@ interface PermissionCapabilityValues { can_read: boolean; can_write: boolean; can_break_glass: boolean; + can_override_variables: boolean; expires_at?: Dayjs | null; allowed_operations?: string[]; restricted_response_fields?: string[]; @@ -52,6 +53,14 @@ function PermissionCapabilityFields({ operations }: { operations: ApiOperation[] + + + @@ -106,6 +115,7 @@ function EditPermissionModal({ can_read: permission.can_read, can_write: permission.can_write, can_break_glass: permission.can_break_glass, + can_override_variables: permission.can_override_variables, expires_at: permission.expires_at ? dayjs(permission.expires_at) : null, allowed_operations: permission.allowed_operations, restricted_response_fields: permission.restricted_response_fields, @@ -122,6 +132,7 @@ function EditPermissionModal({ can_read: values.can_read, can_write: values.can_write, can_break_glass: values.can_break_glass, + can_override_variables: values.can_override_variables, expires_at: values.expires_at ? values.expires_at.toISOString() : null, allowed_operations: values.allowed_operations ?? [], restricted_response_fields: values.restricted_response_fields ?? [], @@ -236,6 +247,7 @@ export function ApiConnectorPermissionsTab({ connectorId }: { connectorId: strin can_read: values.can_read, can_write: values.can_write, can_break_glass: values.can_break_glass, + can_override_variables: values.can_override_variables, expires_at: values.expires_at ? values.expires_at.toISOString() : null, allowed_operations: values.allowed_operations ?? [], restricted_response_fields: values.restricted_response_fields ?? [], @@ -279,7 +291,13 @@ export function ApiConnectorPermissionsTab({ connectorId }: { connectorId: strin
grantMutation.mutate(values)} > @@ -351,6 +369,7 @@ export function ApiConnectorPermissionsTab({ connectorId }: { connectorId: strin { title: t('apiGov.settings.canRead'), dataIndex: 'can_read', render: (v: boolean) => (v ? '✓' : '—') }, { title: t('apiGov.settings.canWrite'), dataIndex: 'can_write', render: (v: boolean) => (v ? '✓' : '—') }, { title: t('apiGov.settings.canBreakGlass'), dataIndex: 'can_break_glass', render: (v: boolean) => (v ? '✓' : '—') }, + { title: t('apiGov.settings.canOverrideVariables'), dataIndex: 'can_override_variables', render: (v: boolean) => (v ? '✓' : '—') }, { title: t('apiGov.settings.expiresAt'), dataIndex: 'expires_at', render: (v: string | null) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—') }, { title: t('apiGov.settings.allowedOperations'), dataIndex: 'allowed_operations', render: (v: string[]) => (v.length ? v.length : t('apiGov.settings.allOperations')) }, { title: t('apiGov.settings.restrictedResponseFields'), dataIndex: 'restricted_response_fields', render: (v: string[]) => (v.length ? v.length : '—') }, @@ -393,6 +412,8 @@ export function ApiConnectorPermissionsTab({ connectorId }: { connectorId: strin { title: t('apiGov.settings.canRead'), dataIndex: 'can_read', render: (v: boolean) => (v ? '✓' : '—') }, { title: t('apiGov.settings.canWrite'), dataIndex: 'can_write', render: (v: boolean) => (v ? '✓' : '—') }, { title: t('apiGov.settings.canBreakGlass'), dataIndex: 'can_break_glass', render: (v: boolean) => (v ? '✓' : '—') }, + { title: t('apiGov.settings.canOverrideVariables'), dataIndex: 'can_override_variables', render: (v: boolean) => (v ? '✓' : '—') }, + { title: t('apiGov.settings.canOverrideVariables'), dataIndex: 'can_override_variables', render: (v: boolean) => (v ? '✓' : '—') }, { title: t('apiGov.settings.expiresAt'), dataIndex: 'expires_at', render: (v: string | null) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—') }, { title: t('apiGov.settings.allowedOperations'), dataIndex: 'allowed_operations', render: (v: string[]) => (v.length ? v.length : t('apiGov.settings.allOperations')) }, { title: t('apiGov.settings.restrictedResponseFields'), dataIndex: 'restricted_response_fields', render: (v: string[]) => (v.length ? v.length : '—') }, diff --git a/frontend/src/components/apigov/ApiConnectorVariablesTab.test.tsx b/frontend/src/components/apigov/ApiConnectorVariablesTab.test.tsx new file mode 100644 index 00000000..5f88197c --- /dev/null +++ b/frontend/src/components/apigov/ApiConnectorVariablesTab.test.tsx @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { App as AntdApp } from 'antd'; +import type { ReactNode } from 'react'; +import '@/i18n'; +import type { ApiConnectorVariable } from '@/types/api'; + +const { + listApiConnectorVariables, + createApiConnectorVariable, + updateApiConnectorVariable, + deleteApiConnectorVariable, + reorderApiConnectorVariables, +} = vi.hoisted(() => ({ + listApiConnectorVariables: vi.fn(), + createApiConnectorVariable: vi.fn(), + updateApiConnectorVariable: vi.fn(), + deleteApiConnectorVariable: vi.fn(), + reorderApiConnectorVariables: vi.fn(), +})); + +vi.mock('@/api/apiConnectorVariables', async () => { + const actual = + await vi.importActual( + '@/api/apiConnectorVariables', + ); + return { + ...actual, + listApiConnectorVariables, + createApiConnectorVariable, + updateApiConnectorVariable, + deleteApiConnectorVariable, + reorderApiConnectorVariables, + }; +}); + +const { ApiConnectorVariablesTab } = await import('./ApiConnectorVariablesTab'); + +function variable(overrides: Partial = {}): ApiConnectorVariable { + return { + id: 'v-1', + connector_id: 'c-1', + name: 'signature', + kind: 'HMAC', + expression: '{{request.body}}', + algorithm: 'HMAC_SHA256', + encoding: 'HEX', + has_secret: true, + target: 'header:X-Signature', + overridable: false, + description: 'Vendor signature', + sort_order: 0, + created_at: '2026-07-20T10:15:00Z', + updated_at: '2026-07-20T10:15:00Z', + ...overrides, + }; +} + +function wrap(node: ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ( + + {node} + + ); +} + +describe('ApiConnectorVariablesTab', () => { + beforeEach(() => { + vi.clearAllMocks(); + listApiConnectorVariables.mockResolvedValue([variable()]); + }); + + it('renders each variable as its placeholder form', async () => { + render(wrap()); + + expect(await screen.findByText('{{signature}}')).toBeInTheDocument(); + expect(screen.getByText('header:X-Signature')).toBeInTheDocument(); + }); + + it('shows the empty state when the connector has no variables', async () => { + listApiConnectorVariables.mockResolvedValue([]); + + render(wrap()); + + expect(await screen.findByText(/No variables yet/i)).toBeInTheDocument(); + }); + + it('flags a stored secret and an overridable variable', async () => { + listApiConnectorVariables.mockResolvedValue([ + variable(), + variable({ id: 'v-2', name: 'nonce', kind: 'RANDOM_HEX', has_secret: false, overridable: true }), + ]); + + render(wrap()); + + expect(await screen.findByText('Secret')).toBeInTheDocument(); + expect(screen.getByText('Overridable')).toBeInTheDocument(); + }); + + it('reorders by sending the full id list in the new order', async () => { + listApiConnectorVariables.mockResolvedValue([ + variable({ id: 'a', name: 'first' }), + variable({ id: 'b', name: 'second' }), + ]); + reorderApiConnectorVariables.mockResolvedValue([]); + + render(wrap()); + await screen.findByText('{{first}}'); + + fireEvent.click(screen.getAllByRole('button', { name: /move later/i })[0] as HTMLElement); + + await waitFor(() => expect(reorderApiConnectorVariables).toHaveBeenCalled()); + expect(reorderApiConnectorVariables.mock.calls[0]?.[1]).toEqual(['b', 'a']); + }); + + it('opens the create modal with the name and kind fields', async () => { + render(wrap()); + await screen.findByText('{{signature}}'); + + fireEvent.click(screen.getByRole('button', { name: /add variable/i })); + + const dialog = await screen.findByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(screen.getByLabelText('Name')).toBeInTheDocument(); + }); + + /** + * Mirrors the server rule and the DB CHECK constraint: a variable holding a secret can never be + * marked overridable, so the switch is disabled rather than merely rejected on save. + */ + it('disables the overridable switch for the secret-bearing HMAC kind', async () => { + render(wrap()); + await screen.findByText('{{signature}}'); + + fireEvent.click(screen.getAllByRole('button', { name: /^edit$/i })[0] as HTMLElement); + await screen.findByText('Edit variable'); + + const overridable = screen.getByRole('switch'); + expect(overridable).toBeDisabled(); + }); + + it('creates a variable from the form values', async () => { + listApiConnectorVariables.mockResolvedValue([]); + createApiConnectorVariable.mockResolvedValue(variable()); + + render(wrap()); + fireEvent.click(screen.getByRole('button', { name: /add variable/i })); + await screen.findByRole('dialog'); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'requestId' } }); + // CONSTANT is the default kind and requires an expression. + fireEvent.change(screen.getByLabelText('Expression'), { target: { value: 'v1' } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(createApiConnectorVariable).toHaveBeenCalled()); + expect(createApiConnectorVariable.mock.calls[0]?.[1]).toMatchObject({ + name: 'requestId', + kind: 'CONSTANT', + expression: 'v1', + overridable: false, + }); + }); + + it('surfaces a save failure without closing the modal', async () => { + listApiConnectorVariables.mockResolvedValue([]); + createApiConnectorVariable.mockRejectedValue(new Error('nope')); + + render(wrap()); + fireEvent.click(screen.getByRole('button', { name: /add variable/i })); + await screen.findByRole('dialog'); + + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'x' } }); + fireEvent.change(screen.getByLabelText('Expression'), { target: { value: 'v' } }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => expect(createApiConnectorVariable).toHaveBeenCalled()); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/apigov/ApiConnectorVariablesTab.tsx b/frontend/src/components/apigov/ApiConnectorVariablesTab.tsx new file mode 100644 index 00000000..68169228 --- /dev/null +++ b/frontend/src/components/apigov/ApiConnectorVariablesTab.tsx @@ -0,0 +1,559 @@ +import { useEffect, useState } from 'react'; +import { App, Alert, Button, Form, Input, Modal, Select, Switch, Table, Tag, Tooltip } from 'antd'; +import { + ArrowDownOutlined, + ArrowUpOutlined, + DeleteOutlined, + EditOutlined, + KeyOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { EmptyState } from '@/components/common/EmptyState'; +import { + apiConnectorVariableKeys, + createApiConnectorVariable, + deleteApiConnectorVariable, + listApiConnectorVariables, + reorderApiConnectorVariables, + updateApiConnectorVariable, +} from '@/api/apiConnectorVariables'; +import { + API_VARIABLE_ALGORITHMS, + API_VARIABLE_ENCODINGS, + API_VARIABLE_KINDS, + apiVariableAlgorithmLabel, + apiVariableEncodingLabel, + apiVariableKindLabel, + enumOptions, +} from '@/utils/enumLabels'; +import { apiErrorMessage } from '@/utils/apiErrors'; +import { showApiError } from '@/utils/showApiError'; +import type { + ApiConnectorVariable, + ApiVariableAlgorithm, + ApiVariableEncoding, + ApiVariableKind, + CreateApiConnectorVariableInput, + UpdateApiConnectorVariableInput, +} from '@/types/api'; + +/** Which optional fields a kind uses. Mirrors the server-side save-time validation exactly. */ +const KIND_RULES: Record< + ApiVariableKind, + { + expression: 'required' | 'optional' | 'none'; + algorithms: readonly ApiVariableAlgorithm[]; + encoding: 'required' | 'optional' | 'none'; + secret: boolean; + } +> = { + CONSTANT: { expression: 'required', algorithms: [], encoding: 'none', secret: false }, + UUID: { expression: 'none', algorithms: [], encoding: 'none', secret: false }, + TIMESTAMP: { expression: 'optional', algorithms: [], encoding: 'none', secret: false }, + EPOCH_MILLIS: { expression: 'none', algorithms: [], encoding: 'none', secret: false }, + RANDOM_HEX: { expression: 'optional', algorithms: [], encoding: 'optional', secret: false }, + HASH: { expression: 'required', algorithms: ['SHA256', 'MD5'], encoding: 'optional', secret: false }, + HMAC: { + expression: 'required', + algorithms: ['HMAC_SHA256', 'HMAC_SHA512'], + encoding: 'optional', + secret: true, + }, + ENCODE: { expression: 'required', algorithms: [], encoding: 'required', secret: false }, +}; + +export function ApiConnectorVariablesTab({ connectorId }: { connectorId: string }) { + const { t } = useTranslation(); + const { message, modal } = App.useApp(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + + const variablesQuery = useQuery({ + queryKey: apiConnectorVariableKeys.list(connectorId), + queryFn: () => listApiConnectorVariables(connectorId), + }); + const variables = variablesQuery.data ?? []; + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: apiConnectorVariableKeys.list(connectorId) }); + + const deleteMutation = useMutation({ + mutationFn: (variableId: string) => deleteApiConnectorVariable(connectorId, variableId), + onSuccess: () => { + void invalidate(); + message.success(t('apiGov.settings.variables.delete_success')); + }, + onError: (err) => { + showApiError(message, err, (e) => + apiErrorMessage(e, () => t('apiGov.settings.variables.delete_error')), + ); + }, + }); + + const reorderMutation = useMutation({ + mutationFn: (variableIds: string[]) => reorderApiConnectorVariables(connectorId, variableIds), + onSuccess: () => void invalidate(), + onError: (err) => { + showApiError(message, err, (e) => + apiErrorMessage(e, () => t('apiGov.settings.variables.reorder_error')), + ); + }, + }); + + const move = (index: number, delta: number) => { + const target = index + delta; + if (target < 0 || target >= variables.length) return; + const next = variables.map((v) => v.id); + const moved = next[index] as string; + next[index] = next[target] as string; + next[target] = moved; + reorderMutation.mutate(next); + }; + + const onDelete = (variable: ApiConnectorVariable) => { + modal.confirm({ + title: t('apiGov.settings.variables.delete_confirm_title'), + content: t('apiGov.settings.variables.delete_confirm_body', { name: variable.name }), + okType: 'danger', + okText: t('apiGov.settings.variables.delete'), + cancelText: t('common.cancel'), + onOk: () => deleteMutation.mutateAsync(variable.id), + }); + }; + + return ( +
+
+
+
{t('apiGov.settings.variables.title')}
+
+ {t('apiGov.settings.variables.description')} +
+
+ +
+ + {!variablesQuery.isLoading && variables.length === 0 ? ( + + ) : ( + + rowKey="id" + size="middle" + loading={variablesQuery.isLoading} + dataSource={variables} + pagination={false} + scroll={{ x: 'max-content' }} + columns={[ + { + title: t('apiGov.settings.variables.col_name'), + width: 200, + render: (_v, variable) => ( + {`{{${variable.name}}}`} + ), + }, + { + title: t('apiGov.settings.variables.col_kind'), + dataIndex: 'kind', + width: 150, + render: (v: ApiVariableKind) => {apiVariableKindLabel(t, v)}, + }, + { + title: t('apiGov.settings.variables.col_expression'), + render: (_v, variable) => ( + + {variable.expression ?? '—'} + + ), + }, + { + title: t('apiGov.settings.variables.col_target'), + width: 180, + render: (_v, variable) => + variable.target ? ( + + {variable.target} + + ) : ( + + {t('apiGov.settings.variables.target_none')} + + ), + }, + { + title: t('apiGov.settings.variables.col_flags'), + width: 170, + render: (_v, variable) => ( + <> + {variable.has_secret && ( + + }> + {t('apiGov.settings.variables.secret_tag')} + + + )} + {variable.overridable && ( + + {t('apiGov.settings.variables.overridable_tag')} + + )} + + ), + }, + { + title: t('apiGov.settings.variables.col_actions'), + width: 170, + align: 'right', + render: (_v, variable, index) => ( + <> +
+ ); +} + +interface VariableFormValues { + name: string; + kind: ApiVariableKind; + expression?: string; + algorithm?: ApiVariableAlgorithm; + encoding?: ApiVariableEncoding; + secret?: string; + target?: string; + overridable: boolean; + description?: string; +} + +interface VariableModalProps { + open: boolean; + connectorId: string; + variable: ApiConnectorVariable | null; + onClose: () => void; +} + +function VariableModal({ open, connectorId, variable, onClose }: VariableModalProps) { + const { t } = useTranslation(); + const { message } = App.useApp(); + const queryClient = useQueryClient(); + const [form] = Form.useForm(); + const kind = Form.useWatch('kind', form) ?? 'CONSTANT'; + const algorithm = Form.useWatch('algorithm', form); + const rules = KIND_RULES[kind]; + + useEffect(() => { + if (!open) return; + if (variable) { + form.setFieldsValue({ + name: variable.name, + kind: variable.kind, + expression: variable.expression ?? undefined, + algorithm: variable.algorithm ?? undefined, + encoding: variable.encoding ?? undefined, + // Never round-tripped: the server does not return it, so an empty field means "unchanged". + secret: undefined, + target: variable.target ?? undefined, + overridable: variable.overridable, + description: variable.description ?? undefined, + }); + } else { + form.resetFields(); + } + }, [open, variable, form]); + + // Changing the kind can invalidate fields the previous kind allowed; clear them so a stale value + // is never submitted (the server would reject it as forbidden-for-this-kind). + useEffect(() => { + if (!open) return; + const next: Partial = {}; + if (rules.algorithms.length === 0) next.algorithm = undefined; + if (rules.encoding === 'none') next.encoding = undefined; + if (rules.expression === 'none') next.expression = undefined; + if (!rules.secret) next.secret = undefined; + if (rules.secret) next.overridable = false; + form.setFieldsValue(next); + }, [kind, open, rules, form]); + + const saveMutation = useMutation({ + mutationFn: (input: UpdateApiConnectorVariableInput) => + variable + ? updateApiConnectorVariable(connectorId, variable.id, input) + : createApiConnectorVariable(connectorId, input as CreateApiConnectorVariableInput), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: apiConnectorVariableKeys.list(connectorId), + }); + message.success(t('apiGov.settings.variables.save_success')); + onClose(); + }, + onError: (err) => { + showApiError(message, err, (e) => + apiErrorMessage(e, () => t('apiGov.settings.variables.save_error')), + ); + }, + }); + + const onFinish = (values: VariableFormValues) => { + const kindRules = KIND_RULES[values.kind]; + saveMutation.mutate({ + name: values.name.trim(), + kind: values.kind, + expression: kindRules.expression === 'none' ? null : (values.expression?.trim() ?? null), + algorithm: kindRules.algorithms.length > 0 ? (values.algorithm ?? null) : null, + encoding: kindRules.encoding === 'none' ? null : (values.encoding ?? null), + // Blank means "leave the stored secret alone" — it is never sent back to the client. + secret: values.secret?.trim() ? values.secret.trim() : undefined, + target: values.target?.trim() ? values.target.trim() : null, + overridable: values.overridable, + description: values.description?.trim() ?? null, + }); + }; + + return ( + form.submit()} + okText={t('common.save')} + cancelText={t('common.cancel')} + confirmLoading={saveMutation.isPending} + destroyOnHidden + width={620} + > + + form={form} + // Namespaces the generated input ids (`connectorVariable_name`, …). Without it AntD emits a + // bare `id="name"`, which collides with the Configuration tab's own name field — that tab + // stays mounted behind this modal, so the duplicate id makes each ` + ); +} diff --git a/frontend/src/components/apigov/ApiRequestComposer.tsx b/frontend/src/components/apigov/ApiRequestComposer.tsx index cb9c73b8..fb1d7bba 100644 --- a/frontend/src/components/apigov/ApiRequestComposer.tsx +++ b/frontend/src/components/apigov/ApiRequestComposer.tsx @@ -1,7 +1,9 @@ import { Button, Input, Radio, Segmented, Space, Tabs, Upload } from 'antd'; import { DeleteOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons'; import { useTranslation } from 'react-i18next'; -import type { ApiBodyType } from '@/types/api'; +import { Tag, Tooltip } from 'antd'; +import type { ApiBodyType, ApiConnectorVariableSummary } from '@/types/api'; +import { apiVariableKindLabel } from '@/utils/enumLabels'; import { KeyValueEditor } from './KeyValueEditor'; import { API_BODY_TYPES, apiBodyTypeLabel } from '@/utils/enumLabels'; import { @@ -16,13 +18,25 @@ interface Props { onChange: (next: ApiRequestComposition) => void; defaultHeaders: Record; onTooLarge: () => void; + /** + * The connector's dynamic variables (AF-613). Drives the Variables tab, which only appears when + * at least one variable exists. Pass `[]` where per-request overrides do not apply. + */ + connectorVariables?: ApiConnectorVariableSummary[]; } -export function ApiRequestComposer({ value, onChange, defaultHeaders, onTooLarge }: Props) { +export function ApiRequestComposer({ + value, + onChange, + defaultHeaders, + onTooLarge, + connectorVariables = [], +}: Props) { const { t } = useTranslation(); const patch = (p: Partial) => onChange({ ...value, ...p }); const defaultHeaderPairs = Object.entries(defaultHeaders).map(([key, v]) => ({ key, value: v })); + const overridableVariables = connectorVariables.filter((v) => v.overridable); const updateFormField = (index: number, p: Partial) => patch({ formFields: value.formFields.map((f, i) => (i === index ? { ...f, ...p } : f)) }); @@ -174,6 +188,48 @@ export function ApiRequestComposer({ value, onChange, defaultHeaders, onTooLarge ), }, { key: 'body', label: t('apiGov.editor.tabBody'), children: bodyTab }, + ...(connectorVariables.length > 0 + ? [ + { + key: 'variables', + label: t('apiGov.editor.tabVariables'), + children: ( +
+
{t('apiGov.editor.variablesHint')}
+ {overridableVariables.length > 0 ? ( +
+
+ {t('apiGov.editor.variableOverrides')} ·{' '} + {t('apiGov.editor.variableOverridesHint')} +
+ patch({ variableOverrides: p })} + /> +
+ ) : ( +
{t('apiGov.editor.noOverridableVariables')}
+ )} +
+
+ {t('apiGov.editor.availableVariables')} +
+
+ {connectorVariables.map((v) => ( + + + {`{{${v.name}}}`}{' '} + {apiVariableKindLabel(t, v.kind)} + + + ))} +
+
+
+ ), + }, + ] + : []), ]} /> ); diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 6ab31a5d..7ee674a4 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -2580,6 +2580,27 @@ "SMTP_CONFIGURE": "SMTP konfigurieren", "LOCALIZATION_CONFIGURE": "Lokalisierung konfigurieren", "SETUP_PROGRESS_VIEW": "Einrichtungsfortschritt anzeigen" + }, + "api_variable_kind": { + "CONSTANT": "Konstante", + "UUID": "Zufällige UUID", + "TIMESTAMP": "Zeitstempel", + "EPOCH_MILLIS": "Epoch-Millisekunden", + "RANDOM_HEX": "Zufällige Bytes", + "HASH": "Hash", + "HMAC": "HMAC-Signatur", + "ENCODE": "Kodieren" + }, + "api_variable_algorithm": { + "HMAC_SHA256": "HMAC-SHA256", + "HMAC_SHA512": "HMAC-SHA512", + "SHA256": "SHA-256", + "MD5": "MD5" + }, + "api_variable_encoding": { + "HEX": "Hexadezimal", + "BASE64": "Base64", + "BASE64URL": "Base64 (URL-sicher, ohne Padding)" } }, "access": { @@ -3130,6 +3151,7 @@ "schemaFileRequired": "Wählen Sie eine Schema-Datei", "tabMasking": "Maskierung", "tabClassification": "Klassifizierung", + "tabVariables": "Variablen", "masking": { "title": "Antwortmaskierung", "description": "Maskiere Felder in den API-Antworten dieses Connectors, bevor sie gespeichert werden. Ein Benutzer in einer Freigabeliste sieht den unmaskierten Wert.", @@ -3241,7 +3263,77 @@ "allowedOperationsPlaceholder": "Alle Operationen (leer lassen)", "allOperations": "Alle", "restrictedResponseFields": "Maskierte Antwortfelder", - "restrictedResponseFieldsPlaceholder": "Punktpfade hinzufügen (z. B. data.ssn)" + "restrictedResponseFieldsPlaceholder": "Punktpfade hinzufügen (z. B. data.ssn)", + "variables": { + "title": "Dynamische Variablen", + "description": "Benannte Werte, die pro Anfrage berechnet und über {{name}}-Platzhalter in Header, Pfad, Query und Body eingesetzt werden — Signaturen, Nonces, Zeitstempel, Idempotenzschlüssel. Sie werden nach der Authentifizierung ausgewertet, sodass ein Ausdruck den aufgelösten Authorization-Header signieren kann.", + "add": "Variable hinzufügen", + "edit": "Bearbeiten", + "delete": "Löschen", + "move_up": "Nach vorne", + "move_down": "Nach hinten", + "create_title": "Variable hinzufügen", + "edit_title": "Variable bearbeiten", + "empty_title": "Noch keine Variablen", + "empty_description": "Fügen Sie eine Variable hinzu, um jede Anfrage an diesen Connector zu signieren, zu stempeln oder zu randomisieren.", + "col_name": "Platzhalter", + "col_kind": "Typ", + "col_expression": "Ausdruck", + "col_target": "Eingefügt in", + "col_flags": "Merkmale", + "col_actions": "Aktionen", + "target_none": "Nur Platzhalter", + "has_secret": "Für diese Variable ist ein gemeinsames Geheimnis hinterlegt", + "secret_tag": "Geheimnis", + "overridable_tag": "Überschreibbar", + "label_name": "Name", + "name_hint": "Verwenden Sie {{name}} in einem Header-Wert, im Pfad, in einem Query-Wert oder im Body.", + "placeholder_name": "signature", + "name_required": "Ein Variablenname ist erforderlich", + "name_invalid": "Mit einem Buchstaben beginnen; nur Buchstaben, Ziffern und Unterstriche (max. 64 Zeichen)", + "label_kind": "Typ", + "kind_required": "Ein Typ ist erforderlich", + "label_expression": "Ausdruck", + "expression_required": "Ein Ausdruck ist erforderlich", + "expression_too_long": "Der Ausdruck darf höchstens 8.192 Zeichen lang sein", + "placeholder_expression": "{{request.headers.Authorization}}{{request.body}}", + "expression_hint_constant": "Ein fester Wert. Andere Variablen und Anfragefelder können referenziert werden.", + "expression_hint_timestamp": "Optionales Datumsformat (UTC). Leer lassen für ISO-8601.", + "expression_hint_random_hex": "Optionale Byte-Anzahl zwischen 1 und 256. Standard ist 16.", + "expression_hint_hash": "Der zu hashende Text. Referenzieren Sie {{request.body}}, {{request.path}} oder eine andere Variable.", + "expression_hint_hmac": "Der zu signierende Text. Anfragefelder beziehen sich auf die Anfrage vor der Ersetzung, sodass ein signierter Body seinen Platzhalter noch enthält.", + "expression_hint_encode": "Der neu zu kodierende Text.", + "label_algorithm": "Algorithmus", + "algorithm_required": "Ein Algorithmus ist erforderlich", + "md5_warning": "MD5 ist nicht kollisionsresistent. Nur verwenden, wenn ein Anbietervertrag es verlangt — niemals für eine Signatur.", + "label_encoding": "Kodierung", + "encoding_required": "Eine Kodierung ist erforderlich", + "encoding_default_hint": "Standardmäßig hexadezimal, wenn leer gelassen.", + "label_secret": "Gemeinsames Geheimnis", + "secret_required": "Ein gemeinsames Geheimnis ist erforderlich", + "secret_too_long": "Das Geheimnis darf höchstens 4.096 Zeichen lang sein", + "secret_hint": "Verschlüsselt gespeichert und nie wieder angezeigt.", + "secret_unchanged_hint": "Ein Geheimnis ist hinterlegt. Leer lassen zum Beibehalten oder neues eingeben zum Ersetzen.", + "label_target": "Einfügen in (optional)", + "target_hint": "header:X-Signature oder query:signature — der Wert wird dort automatisch gesetzt. Leer lassen, um stattdessen einen {{name}}-Platzhalter zu verwenden.", + "placeholder_target": "header:X-Signature", + "target_invalid": "Verwenden Sie header:Name oder query:name", + "label_overridable": "Überschreiben pro Anfrage erlauben", + "overridable_hint": "Einreicher mit der Überschreibungsberechtigung für diesen Connector dürfen einen eigenen Wert angeben.", + "overridable_secret_hint": "Eine Variable mit einem Geheimnis kann nie überschrieben werden.", + "label_description": "Beschreibung", + "placeholder_description": "Signatur der Anbieter-Anfrage", + "description_too_long": "Die Beschreibung darf höchstens 512 Zeichen lang sein", + "save_success": "Variable gespeichert", + "save_error": "Variable konnte nicht gespeichert werden", + "delete_success": "Variable gelöscht", + "delete_error": "Variable konnte nicht gelöscht werden", + "delete_confirm_title": "Diese Variable löschen?", + "delete_confirm_body": "Anfragen, die {{name}} referenzieren, senden den Platzhalter wörtlich.", + "reorder_error": "Reihenfolge konnte nicht geändert werden" + }, + "canOverrideVariables": "Variablen überschreiben", + "canOverrideVariablesHint": "Darf pro Anfrage Werte für die überschreibbaren dynamischen Variablen dieses Connectors angeben." }, "editor": { "title": "API Editor", @@ -3287,7 +3379,13 @@ "searchOperation": "Operationen suchen…", "scheduleFor": "Für später planen", "scheduleHint": "Leer lassen, um direkt nach der Genehmigung auszuführen", - "scheduleInPast": "Die geplante Zeit muss in der Zukunft liegen" + "scheduleInPast": "Die geplante Zeit muss in der Zukunft liegen", + "tabVariables": "Variablen", + "variablesHint": "Dieser Connector berechnet Werte pro Anfrage. Referenzieren Sie einen als {{name}} an beliebiger Stelle.", + "variableOverrides": "Überschreibungen", + "variableOverridesHint": "eigenen Wert für eine überschreibbare Variable angeben", + "noOverridableVariables": "Keine Variable dieses Connectors darf pro Anfrage überschrieben werden.", + "availableVariables": "Verfügbare Platzhalter" }, "requests": { "title": "API Requests", @@ -3339,7 +3437,11 @@ "filterTraceId": "Trace-ID", "filterSpanId": "Span-ID", "downloadResponse": "Vollständige Antwort herunterladen", - "downloadFailed": "Antwort konnte nicht heruntergeladen werden" + "downloadFailed": "Antwort konnte nicht heruntergeladen werden", + "variableOverrides": "Variablen-Überschreibungen", + "variableOverridesHint": "Werte, die der Einreicher für die überschreibbaren Variablen dieses Connectors angegeben hat. Dies sind die Signatur-Eingaben; berechnete Werte wie Signaturen und Nonces werden nie angezeigt.", + "variableName": "Variable", + "variableValue": "Wert" }, "reviews": { "title": "API Reviews", @@ -3353,7 +3455,9 @@ "risk": "Risk", "subtitle": "Verwaltete API-Aufrufe genehmigen oder ablehnen", "searchPlaceholder": "Suche nach Connector, Pfad", - "actions": "Aktionen" + "actions": "Aktionen", + "overrides": "Überschreibungen", + "overridesHint": "Diese Anfrage überschreibt Connector-Variablen" }, "error": "Something went wrong" }, diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 9695267f..f69c09fb 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -2604,6 +2604,27 @@ "SMTP_CONFIGURE": "Configure SMTP", "LOCALIZATION_CONFIGURE": "Configure localization", "SETUP_PROGRESS_VIEW": "View setup progress" + }, + "api_variable_kind": { + "CONSTANT": "Constant", + "UUID": "Random UUID", + "TIMESTAMP": "Timestamp", + "EPOCH_MILLIS": "Epoch milliseconds", + "RANDOM_HEX": "Random bytes", + "HASH": "Hash", + "HMAC": "HMAC signature", + "ENCODE": "Encode" + }, + "api_variable_algorithm": { + "HMAC_SHA256": "HMAC-SHA256", + "HMAC_SHA512": "HMAC-SHA512", + "SHA256": "SHA-256", + "MD5": "MD5" + }, + "api_variable_encoding": { + "HEX": "Hexadecimal", + "BASE64": "Base64", + "BASE64URL": "Base64 (URL-safe, unpadded)" } }, "access": { @@ -3130,6 +3151,7 @@ "schemaFileRequired": "Choose a schema file", "tabMasking": "Masking", "tabClassification": "Classification", + "tabVariables": "Variables", "masking": { "title": "Response masking", "description": "Mask fields in this connector's API responses before they are stored. A user in a reveal list sees the unmasked value.", @@ -3241,7 +3263,77 @@ "allowedOperationsPlaceholder": "All operations (leave empty)", "allOperations": "All", "restrictedResponseFields": "Masked response fields", - "restrictedResponseFieldsPlaceholder": "Add dot-paths (e.g. data.ssn)" + "restrictedResponseFieldsPlaceholder": "Add dot-paths (e.g. data.ssn)", + "variables": { + "title": "Dynamic variables", + "description": "Named values computed per request and substituted into headers, path, query and body via {{name}} placeholders — request signatures, nonces, timestamps, idempotency keys. Evaluated after authentication, so an expression can sign the resolved Authorization header.", + "add": "Add variable", + "edit": "Edit", + "delete": "Delete", + "move_up": "Move earlier", + "move_down": "Move later", + "create_title": "Add variable", + "edit_title": "Edit variable", + "empty_title": "No variables yet", + "empty_description": "Add a variable to sign, stamp or randomise part of every request to this connector.", + "col_name": "Placeholder", + "col_kind": "Kind", + "col_expression": "Expression", + "col_target": "Injects into", + "col_flags": "Flags", + "col_actions": "Actions", + "target_none": "Placeholder only", + "has_secret": "A shared secret is stored for this variable", + "secret_tag": "Secret", + "overridable_tag": "Overridable", + "label_name": "Name", + "name_hint": "Reference it as {{name}} in a header value, the path, a query value or the body.", + "placeholder_name": "signature", + "name_required": "A variable name is required", + "name_invalid": "Start with a letter; letters, digits and underscores only (max 64 characters)", + "label_kind": "Kind", + "kind_required": "A kind is required", + "label_expression": "Expression", + "expression_required": "An expression is required", + "expression_too_long": "The expression must be at most 8,192 characters", + "placeholder_expression": "{{request.headers.Authorization}}{{request.body}}", + "expression_hint_constant": "A fixed value. Other variables and request fields may be referenced.", + "expression_hint_timestamp": "Optional date format pattern (UTC). Leave blank for ISO-8601.", + "expression_hint_random_hex": "Optional byte count between 1 and 256. Defaults to 16.", + "expression_hint_hash": "The text to digest. Reference {{request.body}}, {{request.path}} or another variable.", + "expression_hint_hmac": "The text to sign. Request fields resolve to the request as it stands before substitution, so a body being signed still contains its placeholder.", + "expression_hint_encode": "The text to re-encode.", + "label_algorithm": "Algorithm", + "algorithm_required": "An algorithm is required", + "md5_warning": "MD5 is not collision-resistant. Use it only where a vendor contract requires it, never for a signature.", + "label_encoding": "Encoding", + "encoding_required": "An encoding is required", + "encoding_default_hint": "Defaults to hexadecimal when left blank.", + "label_secret": "Shared secret", + "secret_required": "A shared secret is required", + "secret_too_long": "The secret must be at most 4,096 characters", + "secret_hint": "Encrypted at rest and never shown again.", + "secret_unchanged_hint": "A secret is stored. Leave blank to keep it, or enter a new one to replace it.", + "label_target": "Inject into (optional)", + "target_hint": "header:X-Signature or query:signature — the value is written there automatically. Leave blank to use a {{name}} placeholder instead.", + "placeholder_target": "header:X-Signature", + "target_invalid": "Use header:Name or query:name", + "label_overridable": "Allow per-request override", + "overridable_hint": "Submitters holding the override permission on this connector may supply their own value for this variable.", + "overridable_secret_hint": "A variable that holds a secret can never be overridden.", + "label_description": "Description", + "placeholder_description": "Vendor request signature", + "description_too_long": "The description must be at most 512 characters", + "save_success": "Variable saved", + "save_error": "Could not save the variable", + "delete_success": "Variable deleted", + "delete_error": "Could not delete the variable", + "delete_confirm_title": "Delete this variable?", + "delete_confirm_body": "Requests that reference {{name}} will send the placeholder literally.", + "reorder_error": "Could not reorder the variables" + }, + "canOverrideVariables": "Override variables", + "canOverrideVariablesHint": "May supply per-request values for this connector's overridable dynamic variables." }, "editor": { "title": "API Editor", @@ -3287,7 +3379,13 @@ "searchOperation": "Search operations…", "scheduleFor": "Schedule for later", "scheduleHint": "Leave empty to run right after approval", - "scheduleInPast": "Scheduled time must be in the future" + "scheduleInPast": "Scheduled time must be in the future", + "tabVariables": "Variables", + "variablesHint": "This connector computes values per request. Reference one as {{name}} anywhere in the request.", + "variableOverrides": "Overrides", + "variableOverridesHint": "supply your own value for an overridable variable", + "noOverridableVariables": "None of this connector's variables may be overridden per request.", + "availableVariables": "Available placeholders" }, "requests": { "title": "API Requests", @@ -3339,7 +3437,11 @@ "filterTraceId": "Trace ID", "filterSpanId": "Span ID", "downloadResponse": "Download full response", - "downloadFailed": "Could not download the response" + "downloadFailed": "Could not download the response", + "variableOverrides": "Variable overrides", + "variableOverridesHint": "Values the submitter supplied for this connector's overridable variables. These are the signing inputs; computed values such as signatures and nonces are never shown.", + "variableName": "Variable", + "variableValue": "Value" }, "reviews": { "title": "API Reviews", @@ -3353,7 +3455,9 @@ "risk": "Risk", "subtitle": "Approve or reject governed API calls", "searchPlaceholder": "Search by connector, path", - "actions": "Actions" + "actions": "Actions", + "overrides": "Overrides", + "overridesHint": "This request overrides connector variables" }, "error": "Something went wrong" }, diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index ec6e3cf0..4a165d92 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -2580,6 +2580,27 @@ "SMTP_CONFIGURE": "Configurar SMTP", "LOCALIZATION_CONFIGURE": "Configurar la localización", "SETUP_PROGRESS_VIEW": "Ver el progreso de configuración" + }, + "api_variable_kind": { + "CONSTANT": "Constante", + "UUID": "UUID aleatorio", + "TIMESTAMP": "Marca de tiempo", + "EPOCH_MILLIS": "Milisegundos epoch", + "RANDOM_HEX": "Bytes aleatorios", + "HASH": "Hash", + "HMAC": "Firma HMAC", + "ENCODE": "Codificar" + }, + "api_variable_algorithm": { + "HMAC_SHA256": "HMAC-SHA256", + "HMAC_SHA512": "HMAC-SHA512", + "SHA256": "SHA-256", + "MD5": "MD5" + }, + "api_variable_encoding": { + "HEX": "Hexadecimal", + "BASE64": "Base64", + "BASE64URL": "Base64 (seguro para URL, sin relleno)" } }, "access": { @@ -3130,6 +3151,7 @@ "schemaFileRequired": "Elija un archivo de esquema", "tabMasking": "Enmascaramiento", "tabClassification": "Clasificación", + "tabVariables": "Variables", "masking": { "title": "Enmascaramiento de respuestas", "description": "Enmascara campos en las respuestas de API de este conector antes de almacenarlas. Un usuario en una lista de revelación ve el valor sin enmascarar.", @@ -3241,7 +3263,77 @@ "allowedOperationsPlaceholder": "Todas las operaciones (dejar vacío)", "allOperations": "Todas", "restrictedResponseFields": "Campos de respuesta enmascarados", - "restrictedResponseFieldsPlaceholder": "Añadir rutas de puntos (p. ej. data.ssn)" + "restrictedResponseFieldsPlaceholder": "Añadir rutas de puntos (p. ej. data.ssn)", + "variables": { + "title": "Variables dinámicas", + "description": "Valores con nombre calculados por solicitud y sustituidos en cabeceras, ruta, query y cuerpo mediante marcadores {{name}} — firmas, nonces, marcas de tiempo, claves de idempotencia. Se evalúan tras la autenticación, por lo que una expresión puede firmar la cabecera Authorization ya resuelta.", + "add": "Añadir variable", + "edit": "Editar", + "delete": "Eliminar", + "move_up": "Mover antes", + "move_down": "Mover después", + "create_title": "Añadir variable", + "edit_title": "Editar variable", + "empty_title": "Aún no hay variables", + "empty_description": "Añade una variable para firmar, sellar o aleatorizar parte de cada solicitud a este conector.", + "col_name": "Marcador", + "col_kind": "Tipo", + "col_expression": "Expresión", + "col_target": "Se inyecta en", + "col_flags": "Indicadores", + "col_actions": "Acciones", + "target_none": "Solo marcador", + "has_secret": "Esta variable tiene un secreto compartido almacenado", + "secret_tag": "Secreto", + "overridable_tag": "Sustituible", + "label_name": "Nombre", + "name_hint": "Refiérete a ella como {{name}} en una cabecera, la ruta, un valor de query o el cuerpo.", + "placeholder_name": "signature", + "name_required": "Se requiere un nombre de variable", + "name_invalid": "Empieza por una letra; solo letras, dígitos y guiones bajos (máx. 64 caracteres)", + "label_kind": "Tipo", + "kind_required": "Se requiere un tipo", + "label_expression": "Expresión", + "expression_required": "Se requiere una expresión", + "expression_too_long": "La expresión debe tener como máximo 8.192 caracteres", + "placeholder_expression": "{{request.headers.Authorization}}{{request.body}}", + "expression_hint_constant": "Un valor fijo. Puede referenciar otras variables y campos de la solicitud.", + "expression_hint_timestamp": "Patrón de formato de fecha opcional (UTC). Déjalo vacío para ISO-8601.", + "expression_hint_random_hex": "Número de bytes opcional entre 1 y 256. Por defecto 16.", + "expression_hint_hash": "El texto a resumir. Referencia {{request.body}}, {{request.path}} u otra variable.", + "expression_hint_hmac": "El texto a firmar. Los campos de la solicitud reflejan su estado previo a la sustitución, así que un cuerpo firmado aún contiene su marcador.", + "expression_hint_encode": "El texto a recodificar.", + "label_algorithm": "Algoritmo", + "algorithm_required": "Se requiere un algoritmo", + "md5_warning": "MD5 no es resistente a colisiones. Úsalo solo si un contrato del proveedor lo exige, nunca para una firma.", + "label_encoding": "Codificación", + "encoding_required": "Se requiere una codificación", + "encoding_default_hint": "Por defecto hexadecimal si se deja vacío.", + "label_secret": "Secreto compartido", + "secret_required": "Se requiere un secreto compartido", + "secret_too_long": "El secreto debe tener como máximo 4.096 caracteres", + "secret_hint": "Se cifra en reposo y no se vuelve a mostrar.", + "secret_unchanged_hint": "Hay un secreto almacenado. Déjalo vacío para conservarlo o introduce otro para reemplazarlo.", + "label_target": "Inyectar en (opcional)", + "target_hint": "header:X-Signature o query:signature — el valor se escribe ahí automáticamente. Déjalo vacío para usar un marcador {{name}}.", + "placeholder_target": "header:X-Signature", + "target_invalid": "Usa header:Nombre o query:nombre", + "label_overridable": "Permitir sustitución por solicitud", + "overridable_hint": "Los solicitantes con permiso de sustitución en este conector podrán aportar su propio valor.", + "overridable_secret_hint": "Una variable que contiene un secreto nunca puede sustituirse.", + "label_description": "Descripción", + "placeholder_description": "Firma de solicitud del proveedor", + "description_too_long": "La descripción debe tener como máximo 512 caracteres", + "save_success": "Variable guardada", + "save_error": "No se pudo guardar la variable", + "delete_success": "Variable eliminada", + "delete_error": "No se pudo eliminar la variable", + "delete_confirm_title": "¿Eliminar esta variable?", + "delete_confirm_body": "Las solicitudes que referencien {{name}} enviarán el marcador literalmente.", + "reorder_error": "No se pudo reordenar las variables" + }, + "canOverrideVariables": "Sustituir variables", + "canOverrideVariablesHint": "Puede aportar valores por solicitud para las variables dinámicas sustituibles de este conector." }, "editor": { "title": "API Editor", @@ -3287,7 +3379,13 @@ "searchOperation": "Buscar operaciones…", "scheduleFor": "Programar para más tarde", "scheduleHint": "Déjelo vacío para ejecutar justo después de la aprobación", - "scheduleInPast": "La hora programada debe ser futura" + "scheduleInPast": "La hora programada debe ser futura", + "tabVariables": "Variables", + "variablesHint": "Este conector calcula valores por solicitud. Refiérete a uno como {{name}} en cualquier parte.", + "variableOverrides": "Sustituciones", + "variableOverridesHint": "aporta tu propio valor para una variable sustituible", + "noOverridableVariables": "Ninguna variable de este conector puede sustituirse por solicitud.", + "availableVariables": "Marcadores disponibles" }, "requests": { "title": "API Requests", @@ -3339,7 +3437,11 @@ "filterTraceId": "ID de traza", "filterSpanId": "ID de span", "downloadResponse": "Descargar respuesta completa", - "downloadFailed": "No se pudo descargar la respuesta" + "downloadFailed": "No se pudo descargar la respuesta", + "variableOverrides": "Sustituciones de variables", + "variableOverridesHint": "Valores que el solicitante aportó para las variables sustituibles de este conector. Son las entradas de firma; los valores calculados como firmas y nonces nunca se muestran.", + "variableName": "Variable", + "variableValue": "Valor" }, "reviews": { "title": "API Reviews", @@ -3353,7 +3455,9 @@ "risk": "Risk", "subtitle": "Aprobar o rechazar llamadas API gobernadas", "searchPlaceholder": "Buscar por conector, ruta", - "actions": "Acciones" + "actions": "Acciones", + "overrides": "Sustituciones", + "overridesHint": "Esta solicitud sustituye variables del conector" }, "error": "Something went wrong" }, diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index f84314de..f92665e3 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -2580,6 +2580,27 @@ "SMTP_CONFIGURE": "Configurer le SMTP", "LOCALIZATION_CONFIGURE": "Configurer la localisation", "SETUP_PROGRESS_VIEW": "Voir la progression de la configuration" + }, + "api_variable_kind": { + "CONSTANT": "Constante", + "UUID": "UUID aléatoire", + "TIMESTAMP": "Horodatage", + "EPOCH_MILLIS": "Millisecondes epoch", + "RANDOM_HEX": "Octets aléatoires", + "HASH": "Hachage", + "HMAC": "Signature HMAC", + "ENCODE": "Encoder" + }, + "api_variable_algorithm": { + "HMAC_SHA256": "HMAC-SHA256", + "HMAC_SHA512": "HMAC-SHA512", + "SHA256": "SHA-256", + "MD5": "MD5" + }, + "api_variable_encoding": { + "HEX": "Hexadécimal", + "BASE64": "Base64", + "BASE64URL": "Base64 (URL, sans remplissage)" } }, "access": { @@ -3130,6 +3151,7 @@ "schemaFileRequired": "Choisissez un fichier de schéma", "tabMasking": "Masquage", "tabClassification": "Classification", + "tabVariables": "Variables", "masking": { "title": "Masquage des réponses", "description": "Masquez des champs dans les réponses d'API de ce connecteur avant leur stockage. Un utilisateur figurant dans une liste de révélation voit la valeur non masquée.", @@ -3241,7 +3263,77 @@ "allowedOperationsPlaceholder": "Toutes les opérations (laisser vide)", "allOperations": "Toutes", "restrictedResponseFields": "Champs de réponse masqués", - "restrictedResponseFieldsPlaceholder": "Ajouter des chemins (ex. data.ssn)" + "restrictedResponseFieldsPlaceholder": "Ajouter des chemins (ex. data.ssn)", + "variables": { + "title": "Variables dynamiques", + "description": "Valeurs nommées calculées à chaque requête et substituées dans les en-têtes, le chemin, la query et le corps via des marqueurs {{name}} — signatures, nonces, horodatages, clés d'idempotence. Elles sont évaluées après l'authentification, si bien qu'une expression peut signer l'en-tête Authorization résolu.", + "add": "Ajouter une variable", + "edit": "Modifier", + "delete": "Supprimer", + "move_up": "Déplacer avant", + "move_down": "Déplacer après", + "create_title": "Ajouter une variable", + "edit_title": "Modifier la variable", + "empty_title": "Aucune variable pour l'instant", + "empty_description": "Ajoutez une variable pour signer, horodater ou aléatoiriser une partie de chaque requête vers ce connecteur.", + "col_name": "Marqueur", + "col_kind": "Type", + "col_expression": "Expression", + "col_target": "Injectée dans", + "col_flags": "Indicateurs", + "col_actions": "Actions", + "target_none": "Marqueur uniquement", + "has_secret": "Un secret partagé est enregistré pour cette variable", + "secret_tag": "Secret", + "overridable_tag": "Substituable", + "label_name": "Nom", + "name_hint": "Référencez-la par {{name}} dans un en-tête, le chemin, une valeur de query ou le corps.", + "placeholder_name": "signature", + "name_required": "Un nom de variable est requis", + "name_invalid": "Commencez par une lettre ; lettres, chiffres et tirets bas uniquement (64 caractères max)", + "label_kind": "Type", + "kind_required": "Un type est requis", + "label_expression": "Expression", + "expression_required": "Une expression est requise", + "expression_too_long": "L'expression ne doit pas dépasser 8 192 caractères", + "placeholder_expression": "{{request.headers.Authorization}}{{request.body}}", + "expression_hint_constant": "Une valeur fixe. D'autres variables et champs de la requête peuvent être référencés.", + "expression_hint_timestamp": "Format de date optionnel (UTC). Laissez vide pour ISO-8601.", + "expression_hint_random_hex": "Nombre d'octets optionnel entre 1 et 256. 16 par défaut.", + "expression_hint_hash": "Le texte à hacher. Référencez {{request.body}}, {{request.path}} ou une autre variable.", + "expression_hint_hmac": "Le texte à signer. Les champs de la requête décrivent son état avant substitution, si bien qu'un corps signé contient encore son marqueur.", + "expression_hint_encode": "Le texte à réencoder.", + "label_algorithm": "Algorithme", + "algorithm_required": "Un algorithme est requis", + "md5_warning": "MD5 n'est pas résistant aux collisions. Ne l'utilisez que si un contrat fournisseur l'impose, jamais pour une signature.", + "label_encoding": "Encodage", + "encoding_required": "Un encodage est requis", + "encoding_default_hint": "Hexadécimal par défaut si laissé vide.", + "label_secret": "Secret partagé", + "secret_required": "Un secret partagé est requis", + "secret_too_long": "Le secret ne doit pas dépasser 4 096 caractères", + "secret_hint": "Chiffré au repos et jamais réaffiché.", + "secret_unchanged_hint": "Un secret est enregistré. Laissez vide pour le conserver, ou saisissez-en un nouveau pour le remplacer.", + "label_target": "Injecter dans (optionnel)", + "target_hint": "header:X-Signature ou query:signature — la valeur y est écrite automatiquement. Laissez vide pour utiliser un marqueur {{name}}.", + "placeholder_target": "header:X-Signature", + "target_invalid": "Utilisez header:Nom ou query:nom", + "label_overridable": "Autoriser la substitution par requête", + "overridable_hint": "Les demandeurs disposant de la permission de substitution sur ce connecteur pourront fournir leur propre valeur.", + "overridable_secret_hint": "Une variable contenant un secret ne peut jamais être substituée.", + "label_description": "Description", + "placeholder_description": "Signature de requête du fournisseur", + "description_too_long": "La description ne doit pas dépasser 512 caractères", + "save_success": "Variable enregistrée", + "save_error": "Impossible d'enregistrer la variable", + "delete_success": "Variable supprimée", + "delete_error": "Impossible de supprimer la variable", + "delete_confirm_title": "Supprimer cette variable ?", + "delete_confirm_body": "Les requêtes référençant {{name}} enverront le marqueur littéralement.", + "reorder_error": "Impossible de réordonner les variables" + }, + "canOverrideVariables": "Substituer les variables", + "canOverrideVariablesHint": "Peut fournir des valeurs par requête pour les variables dynamiques substituables de ce connecteur." }, "editor": { "title": "API Editor", @@ -3287,7 +3379,13 @@ "searchOperation": "Rechercher des opérations…", "scheduleFor": "Planifier pour plus tard", "scheduleHint": "Laisser vide pour exécuter juste après l'approbation", - "scheduleInPast": "L'heure planifiée doit être dans le futur" + "scheduleInPast": "L'heure planifiée doit être dans le futur", + "tabVariables": "Variables", + "variablesHint": "Ce connecteur calcule des valeurs à chaque requête. Référencez-en une par {{name}} n'importe où.", + "variableOverrides": "Substitutions", + "variableOverridesHint": "fournissez votre propre valeur pour une variable substituable", + "noOverridableVariables": "Aucune variable de ce connecteur ne peut être substituée par requête.", + "availableVariables": "Marqueurs disponibles" }, "requests": { "title": "API Requests", @@ -3339,7 +3437,11 @@ "filterTraceId": "ID de trace", "filterSpanId": "ID de span", "downloadResponse": "Télécharger la réponse complète", - "downloadFailed": "Impossible de télécharger la réponse" + "downloadFailed": "Impossible de télécharger la réponse", + "variableOverrides": "Substitutions de variables", + "variableOverridesHint": "Valeurs fournies par le demandeur pour les variables substituables de ce connecteur. Ce sont les entrées de signature ; les valeurs calculées telles que signatures et nonces ne sont jamais affichées.", + "variableName": "Variable", + "variableValue": "Valeur" }, "reviews": { "title": "API Reviews", @@ -3353,7 +3455,9 @@ "risk": "Risk", "subtitle": "Approuver ou rejeter les appels API gouvernés", "searchPlaceholder": "Rechercher par connecteur, chemin", - "actions": "Actions" + "actions": "Actions", + "overrides": "Substitutions", + "overridesHint": "Cette requête substitue des variables du connecteur" }, "error": "Something went wrong" }, diff --git a/frontend/src/locales/hy.json b/frontend/src/locales/hy.json index be971037..64e8e7f1 100644 --- a/frontend/src/locales/hy.json +++ b/frontend/src/locales/hy.json @@ -2580,6 +2580,27 @@ "SMTP_CONFIGURE": "Կազմաձևել SMTP-ն", "LOCALIZATION_CONFIGURE": "Կազմաձևել տեղայնացումը", "SETUP_PROGRESS_VIEW": "Դիտել կարգավորման ընթացքը" + }, + "api_variable_kind": { + "CONSTANT": "Հաստատուն", + "UUID": "Պատահական UUID", + "TIMESTAMP": "Ժամանակադրոշմ", + "EPOCH_MILLIS": "Epoch միլիվայրկյաններ", + "RANDOM_HEX": "Պատահական բայթեր", + "HASH": "Հեշ", + "HMAC": "HMAC ստորագրություն", + "ENCODE": "Կոդավորել" + }, + "api_variable_algorithm": { + "HMAC_SHA256": "HMAC-SHA256", + "HMAC_SHA512": "HMAC-SHA512", + "SHA256": "SHA-256", + "MD5": "MD5" + }, + "api_variable_encoding": { + "HEX": "Տասնվեցական", + "BASE64": "Base64", + "BASE64URL": "Base64 (URL-անվտանգ, առանց լրացման)" } }, "access": { @@ -3130,6 +3151,7 @@ "schemaFileRequired": "Ընտրեք սխեմայի ֆայլ", "tabMasking": "Քողարկում", "tabClassification": "Դասակարգում", + "tabVariables": "Փոփոխականներ", "masking": { "title": "Պատասխանի քողարկում", "description": "Քողարկեք այս միակցիչի API պատասխանների դաշտերը նախքան դրանց պահպանումը։ Բացահայտման ցանկում գտնվող օգտատերը տեսնում է չքողարկված արժեքը։", @@ -3241,7 +3263,77 @@ "allowedOperationsPlaceholder": "Բոլոր գործողությունները (թողնել դատարկ)", "allOperations": "Բոլորը", "restrictedResponseFields": "Դիմակավորված պատասխանի դաշտեր", - "restrictedResponseFieldsPlaceholder": "Ավելացնել կետային ուղիներ (օր. data.ssn)" + "restrictedResponseFieldsPlaceholder": "Ավելացնել կետային ուղիներ (օր. data.ssn)", + "variables": { + "title": "Դինամիկ փոփոխականներ", + "description": "Անվանված արժեքներ, որոնք հաշվարկվում են յուրաքանչյուր հարցման համար և {{name}} տեղապահների միջոցով տեղադրվում են վերնագրերում, ուղում, հարցման տողում և մարմնում՝ ստորագրություններ, nonce-ներ, ժամանակադրոշմներ, իդեմպոտենտության բանալիներ։ Գնահատվում են վավերացումից հետո, ուստի արտահայտությունը կարող է ստորագրել լուծված Authorization վերնագիրը։", + "add": "Ավելացնել փոփոխական", + "edit": "Խմբագրել", + "delete": "Ջնջել", + "move_up": "Տեղափոխել վեր", + "move_down": "Տեղափոխել վար", + "create_title": "Ավելացնել փոփոխական", + "edit_title": "Խմբագրել փոփոխականը", + "empty_title": "Փոփոխականներ դեռ չկան", + "empty_description": "Ավելացրեք փոփոխական՝ այս միակցիչին ուղղված յուրաքանչյուր հարցում ստորագրելու, ժամանակադրոշմելու կամ պատահականացնելու համար։", + "col_name": "Տեղապահ", + "col_kind": "Տեսակ", + "col_expression": "Արտահայտություն", + "col_target": "Ներարկվում է", + "col_flags": "Նշիչներ", + "col_actions": "Գործողություններ", + "target_none": "Միայն տեղապահ", + "has_secret": "Այս փոփոխականի համար պահվում է ընդհանուր գաղտնիք", + "secret_tag": "Գաղտնիք", + "overridable_tag": "Վերասահմանելի", + "label_name": "Անուն", + "name_hint": "Հղվեք որպես {{name}} վերնագրի արժեքում, ուղում, հարցման արժեքում կամ մարմնում։", + "placeholder_name": "signature", + "name_required": "Փոփոխականի անունը պարտադիր է", + "name_invalid": "Սկսեք տառով. միայն տառեր, թվեր և ընդգծումներ (առավելագույնը 64 նիշ)", + "label_kind": "Տեսակ", + "kind_required": "Տեսակը պարտադիր է", + "label_expression": "Արտահայտություն", + "expression_required": "Արտահայտությունը պարտադիր է", + "expression_too_long": "Արտահայտությունը պետք է լինի առավելագույնը 8192 նիշ", + "placeholder_expression": "{{request.headers.Authorization}}{{request.body}}", + "expression_hint_constant": "Ֆիքսված արժեք։ Կարելի է հղվել այլ փոփոխականների և հարցման դաշտերի։", + "expression_hint_timestamp": "Ամսաթվի ձևաչափի ընտրովի շաբլոն (UTC)։ Թողեք դատարկ՝ ISO-8601-ի համար։", + "expression_hint_random_hex": "Բայթերի ընտրովի քանակ՝ 1-ից 256։ Կանխադրված՝ 16։", + "expression_hint_hash": "Հեշավորվող տեքստը։ Հղվեք {{request.body}}, {{request.path}} կամ այլ փոփոխականի։", + "expression_hint_hmac": "Ստորագրվող տեքստը։ Հարցման դաշտերն արտացոլում են հարցումը մինչև փոխարինումը, ուստի ստորագրվող մարմինը դեռ պարունակում է իր տեղապահը։", + "expression_hint_encode": "Վերակոդավորվող տեքստը։", + "label_algorithm": "Ալգորիթմ", + "algorithm_required": "Ալգորիթմը պարտադիր է", + "md5_warning": "MD5-ը բախումակայուն չէ։ Օգտագործեք միայն այն դեպքում, երբ մատակարարի պայմանագիրը պահանջում է, երբեք ոչ ստորագրության համար։", + "label_encoding": "Կոդավորում", + "encoding_required": "Կոդավորումը պարտադիր է", + "encoding_default_hint": "Դատարկ թողնելու դեպքում կանխադրված է տասնվեցական։", + "label_secret": "Ընդհանուր գաղտնիք", + "secret_required": "Ընդհանուր գաղտնիքը պարտադիր է", + "secret_too_long": "Գաղտնիքը պետք է լինի առավելագույնը 4096 նիշ", + "secret_hint": "Պահվում է գաղտնագրված և այլևս ցուցադրվում չէ։", + "secret_unchanged_hint": "Գաղտնիք պահված է։ Թողեք դատարկ՝ պահպանելու, կամ մուտքագրեք նորը՝ փոխարինելու համար։", + "label_target": "Ներարկել (ընտրովի)", + "target_hint": "header:X-Signature կամ query:signature — արժեքն ավտոմատ գրվում է այնտեղ։ Թողեք դատարկ՝ {{name}} տեղապահ օգտագործելու համար։", + "placeholder_target": "header:X-Signature", + "target_invalid": "Օգտագործեք header:Անուն կամ query:անուն", + "label_overridable": "Թույլատրել վերասահմանում ըստ հարցման", + "overridable_hint": "Այս միակցիչի վերասահմանման թույլտվություն ունեցող ներկայացնողները կարող են տրամադրել իրենց արժեքը։", + "overridable_secret_hint": "Գաղտնիք պարունակող փոփոխականը երբեք չի կարող վերասահմանվել։", + "label_description": "Նկարագրություն", + "placeholder_description": "Մատակարարի հարցման ստորագրություն", + "description_too_long": "Նկարագրությունը պետք է լինի առավելագույնը 512 նիշ", + "save_success": "Փոփոխականը պահպանվեց", + "save_error": "Չհաջողվեց պահպանել փոփոխականը", + "delete_success": "Փոփոխականը ջնջվեց", + "delete_error": "Չհաջողվեց ջնջել փոփոխականը", + "delete_confirm_title": "Ջնջե՞լ այս փոփոխականը", + "delete_confirm_body": "{{name}}-ին հղվող հարցումները կուղարկեն տեղապահը բառացիորեն։", + "reorder_error": "Չհաջողվեց փոխել փոփոխականների հերթականությունը" + }, + "canOverrideVariables": "Վերասահմանել փոփոխականները", + "canOverrideVariablesHint": "Կարող է տրամադրել արժեքներ ըստ հարցման այս միակցիչի վերասահմանելի դինամիկ փոփոխականների համար։" }, "editor": { "title": "API Editor", @@ -3287,7 +3379,13 @@ "searchOperation": "Որոնել գործողություններ…", "scheduleFor": "Պլանավորել ավելի ուշ", "scheduleHint": "Թողեք դատարկ՝ հաստատումից անմիջապես հետո կատարելու համար", - "scheduleInPast": "Պլանավորված ժամանակը պետք է լինի ապագայում" + "scheduleInPast": "Պլանավորված ժամանակը պետք է լինի ապագայում", + "tabVariables": "Փոփոխականներ", + "variablesHint": "Այս միակցիչը հաշվարկում է արժեքներ յուրաքանչյուր հարցման համար։ Հղվեք որպես {{name}} ցանկացած տեղում։", + "variableOverrides": "Վերասահմանումներ", + "variableOverridesHint": "տրամադրեք ձեր արժեքը վերասահմանելի փոփոխականի համար", + "noOverridableVariables": "Այս միակցիչի ոչ մի փոփոխական չի կարող վերասահմանվել ըստ հարցման։", + "availableVariables": "Հասանելի տեղապահներ" }, "requests": { "title": "API Requests", @@ -3339,7 +3437,11 @@ "filterTraceId": "Հետագծի ID", "filterSpanId": "Span ID", "downloadResponse": "Ներբեռնել ամբողջ պատասխանը", - "downloadFailed": "Չհաջողվեց ներբեռնել պատասխանը" + "downloadFailed": "Չհաջողվեց ներբեռնել պատասխանը", + "variableOverrides": "Փոփոխականների վերասահմանումներ", + "variableOverridesHint": "Արժեքներ, որոնք ներկայացնողը տրամադրել է այս միակցիչի վերասահմանելի փոփոխականների համար։ Սրանք ստորագրման մուտքերն են. հաշվարկված արժեքները՝ ստորագրություններ և nonce-ներ, երբեք չեն ցուցադրվում։", + "variableName": "Փոփոխական", + "variableValue": "Արժեք" }, "reviews": { "title": "API Reviews", @@ -3353,7 +3455,9 @@ "risk": "Risk", "subtitle": "Հաստատել կամ մերժել կառավարվող API կանչերը", "searchPlaceholder": "Որոնել ըստ կոնեկտորի, ուղու", - "actions": "Գործողություններ" + "actions": "Գործողություններ", + "overrides": "Վերասահմանումներ", + "overridesHint": "Այս հարցումը վերասահմանում է միակցիչի փոփոխականներ" }, "error": "Something went wrong" }, diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index c1c3a5d1..bf9b62b8 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -2580,6 +2580,27 @@ "SMTP_CONFIGURE": "Настраивать SMTP", "LOCALIZATION_CONFIGURE": "Настраивать локализацию", "SETUP_PROGRESS_VIEW": "Просматривать ход настройки" + }, + "api_variable_kind": { + "CONSTANT": "Константа", + "UUID": "Случайный UUID", + "TIMESTAMP": "Метка времени", + "EPOCH_MILLIS": "Миллисекунды epoch", + "RANDOM_HEX": "Случайные байты", + "HASH": "Хеш", + "HMAC": "Подпись HMAC", + "ENCODE": "Кодировать" + }, + "api_variable_algorithm": { + "HMAC_SHA256": "HMAC-SHA256", + "HMAC_SHA512": "HMAC-SHA512", + "SHA256": "SHA-256", + "MD5": "MD5" + }, + "api_variable_encoding": { + "HEX": "Шестнадцатеричная", + "BASE64": "Base64", + "BASE64URL": "Base64 (URL-безопасная, без заполнения)" } }, "access": { @@ -3130,6 +3151,7 @@ "schemaFileRequired": "Выберите файл схемы", "tabMasking": "Маскирование", "tabClassification": "Классификация", + "tabVariables": "Переменные", "masking": { "title": "Маскирование ответов", "description": "Маскируйте поля в ответах API этого коннектора перед их сохранением. Пользователь из списка раскрытия видит немаскированное значение.", @@ -3241,7 +3263,77 @@ "allowedOperationsPlaceholder": "Все операции (оставьте пустым)", "allOperations": "Все", "restrictedResponseFields": "Маскируемые поля ответа", - "restrictedResponseFieldsPlaceholder": "Добавьте пути (напр. data.ssn)" + "restrictedResponseFieldsPlaceholder": "Добавьте пути (напр. data.ssn)", + "variables": { + "title": "Динамические переменные", + "description": "Именованные значения, вычисляемые для каждого запроса и подставляемые в заголовки, путь, строку запроса и тело через плейсхолдеры {{name}} — подписи, одноразовые числа, метки времени, ключи идемпотентности. Вычисляются после аутентификации, поэтому выражение может подписать уже сформированный заголовок Authorization.", + "add": "Добавить переменную", + "edit": "Изменить", + "delete": "Удалить", + "move_up": "Переместить выше", + "move_down": "Переместить ниже", + "create_title": "Добавить переменную", + "edit_title": "Изменить переменную", + "empty_title": "Переменных пока нет", + "empty_description": "Добавьте переменную, чтобы подписывать, помечать временем или рандомизировать часть каждого запроса к этому коннектору.", + "col_name": "Плейсхолдер", + "col_kind": "Тип", + "col_expression": "Выражение", + "col_target": "Подставляется в", + "col_flags": "Признаки", + "col_actions": "Действия", + "target_none": "Только плейсхолдер", + "has_secret": "Для этой переменной сохранён общий секрет", + "secret_tag": "Секрет", + "overridable_tag": "Переопределяемая", + "label_name": "Имя", + "name_hint": "Ссылайтесь как {{name}} в значении заголовка, пути, значении строки запроса или теле.", + "placeholder_name": "signature", + "name_required": "Требуется имя переменной", + "name_invalid": "Начните с буквы; только буквы, цифры и подчёркивания (не более 64 символов)", + "label_kind": "Тип", + "kind_required": "Требуется тип", + "label_expression": "Выражение", + "expression_required": "Требуется выражение", + "expression_too_long": "Выражение не должно превышать 8 192 символов", + "placeholder_expression": "{{request.headers.Authorization}}{{request.body}}", + "expression_hint_constant": "Фиксированное значение. Можно ссылаться на другие переменные и поля запроса.", + "expression_hint_timestamp": "Необязательный шаблон формата даты (UTC). Оставьте пустым для ISO-8601.", + "expression_hint_random_hex": "Необязательное число байт от 1 до 256. По умолчанию 16.", + "expression_hint_hash": "Текст для хеширования. Ссылайтесь на {{request.body}}, {{request.path}} или другую переменную.", + "expression_hint_hmac": "Подписываемый текст. Поля запроса отражают его состояние до подстановки, поэтому подписываемое тело всё ещё содержит свой плейсхолдер.", + "expression_hint_encode": "Текст для перекодирования.", + "label_algorithm": "Алгоритм", + "algorithm_required": "Требуется алгоритм", + "md5_warning": "MD5 не устойчив к коллизиям. Используйте только если этого требует контракт поставщика, и никогда для подписи.", + "label_encoding": "Кодировка", + "encoding_required": "Требуется кодировка", + "encoding_default_hint": "По умолчанию шестнадцатеричная, если оставить пустым.", + "label_secret": "Общий секрет", + "secret_required": "Требуется общий секрет", + "secret_too_long": "Секрет не должен превышать 4 096 символов", + "secret_hint": "Шифруется при хранении и больше не отображается.", + "secret_unchanged_hint": "Секрет сохранён. Оставьте пустым, чтобы сохранить, или введите новый для замены.", + "label_target": "Подставлять в (необязательно)", + "target_hint": "header:X-Signature или query:signature — значение записывается туда автоматически. Оставьте пустым, чтобы использовать плейсхолдер {{name}}.", + "placeholder_target": "header:X-Signature", + "target_invalid": "Используйте header:Имя или query:имя", + "label_overridable": "Разрешить переопределение в запросе", + "overridable_hint": "Отправители с правом переопределения на этом коннекторе смогут указать своё значение.", + "overridable_secret_hint": "Переменную, содержащую секрет, переопределить нельзя.", + "label_description": "Описание", + "placeholder_description": "Подпись запроса поставщика", + "description_too_long": "Описание не должно превышать 512 символов", + "save_success": "Переменная сохранена", + "save_error": "Не удалось сохранить переменную", + "delete_success": "Переменная удалена", + "delete_error": "Не удалось удалить переменную", + "delete_confirm_title": "Удалить эту переменную?", + "delete_confirm_body": "Запросы, ссылающиеся на {{name}}, отправят плейсхолдер буквально.", + "reorder_error": "Не удалось изменить порядок переменных" + }, + "canOverrideVariables": "Переопределять переменные", + "canOverrideVariablesHint": "Может указывать значения в запросе для переопределяемых динамических переменных этого коннектора." }, "editor": { "title": "API Editor", @@ -3287,7 +3379,13 @@ "searchOperation": "Поиск операций…", "scheduleFor": "Запланировать на потом", "scheduleHint": "Оставьте пустым для запуска сразу после одобрения", - "scheduleInPast": "Запланированное время должно быть в будущем" + "scheduleInPast": "Запланированное время должно быть в будущем", + "tabVariables": "Переменные", + "variablesHint": "Этот коннектор вычисляет значения для каждого запроса. Ссылайтесь как {{name}} в любом месте.", + "variableOverrides": "Переопределения", + "variableOverridesHint": "укажите своё значение для переопределяемой переменной", + "noOverridableVariables": "Ни одну переменную этого коннектора нельзя переопределить в запросе.", + "availableVariables": "Доступные плейсхолдеры" }, "requests": { "title": "API Requests", @@ -3339,7 +3437,11 @@ "filterTraceId": "ID трассировки", "filterSpanId": "ID спана", "downloadResponse": "Скачать полный ответ", - "downloadFailed": "Не удалось скачать ответ" + "downloadFailed": "Не удалось скачать ответ", + "variableOverrides": "Переопределения переменных", + "variableOverridesHint": "Значения, которые отправитель указал для переопределяемых переменных этого коннектора. Это входные данные подписи; вычисленные значения — подписи и одноразовые числа — никогда не показываются.", + "variableName": "Переменная", + "variableValue": "Значение" }, "reviews": { "title": "API Reviews", @@ -3353,7 +3455,9 @@ "risk": "Risk", "subtitle": "Одобрение или отклонение управляемых API-вызовов", "searchPlaceholder": "Поиск по коннектору, пути", - "actions": "Действия" + "actions": "Действия", + "overrides": "Переопределения", + "overridesHint": "Этот запрос переопределяет переменные коннектора" }, "error": "Something went wrong" }, diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 5f3af727..ba6c9377 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -2580,6 +2580,27 @@ "SMTP_CONFIGURE": "配置 SMTP", "LOCALIZATION_CONFIGURE": "配置本地化", "SETUP_PROGRESS_VIEW": "查看设置进度" + }, + "api_variable_kind": { + "CONSTANT": "常量", + "UUID": "随机 UUID", + "TIMESTAMP": "时间戳", + "EPOCH_MILLIS": "Epoch 毫秒", + "RANDOM_HEX": "随机字节", + "HASH": "哈希", + "HMAC": "HMAC 签名", + "ENCODE": "编码" + }, + "api_variable_algorithm": { + "HMAC_SHA256": "HMAC-SHA256", + "HMAC_SHA512": "HMAC-SHA512", + "SHA256": "SHA-256", + "MD5": "MD5" + }, + "api_variable_encoding": { + "HEX": "十六进制", + "BASE64": "Base64", + "BASE64URL": "Base64(URL 安全、无填充)" } }, "access": { @@ -3130,6 +3151,7 @@ "schemaFileRequired": "请选择模式文件", "tabMasking": "脱敏", "tabClassification": "分类", + "tabVariables": "变量", "masking": { "title": "响应脱敏", "description": "在存储之前对此连接器的 API 响应中的字段进行脱敏。位于揭示列表中的用户可看到未脱敏的值。", @@ -3241,7 +3263,77 @@ "allowedOperationsPlaceholder": "所有操作(留空)", "allOperations": "全部", "restrictedResponseFields": "掩码响应字段", - "restrictedResponseFieldsPlaceholder": "添加点路径(如 data.ssn)" + "restrictedResponseFieldsPlaceholder": "添加点路径(如 data.ssn)", + "variables": { + "title": "动态变量", + "description": "按请求计算的命名值,通过 {{name}} 占位符替换到请求头、路径、查询参数和请求体中——请求签名、随机数、时间戳、幂等键。在身份验证之后求值,因此表达式可以对已解析的 Authorization 请求头进行签名。", + "add": "添加变量", + "edit": "编辑", + "delete": "删除", + "move_up": "上移", + "move_down": "下移", + "create_title": "添加变量", + "edit_title": "编辑变量", + "empty_title": "暂无变量", + "empty_description": "添加变量以对发往该连接器的每个请求进行签名、加时间戳或随机化。", + "col_name": "占位符", + "col_kind": "类型", + "col_expression": "表达式", + "col_target": "注入到", + "col_flags": "标记", + "col_actions": "操作", + "target_none": "仅占位符", + "has_secret": "该变量已存储共享密钥", + "secret_tag": "密钥", + "overridable_tag": "可覆盖", + "label_name": "名称", + "name_hint": "在请求头值、路径、查询值或请求体中以 {{name}} 引用。", + "placeholder_name": "signature", + "name_required": "必须提供变量名称", + "name_invalid": "以字母开头;仅限字母、数字和下划线(最多 64 个字符)", + "label_kind": "类型", + "kind_required": "必须提供类型", + "label_expression": "表达式", + "expression_required": "必须提供表达式", + "expression_too_long": "表达式最多 8,192 个字符", + "placeholder_expression": "{{request.headers.Authorization}}{{request.body}}", + "expression_hint_constant": "固定值。可引用其他变量和请求字段。", + "expression_hint_timestamp": "可选的日期格式(UTC)。留空则使用 ISO-8601。", + "expression_hint_random_hex": "可选字节数,介于 1 和 256 之间。默认 16。", + "expression_hint_hash": "要摘要的文本。可引用 {{request.body}}、{{request.path}} 或其他变量。", + "expression_hint_hmac": "要签名的文本。请求字段反映替换之前的请求状态,因此被签名的请求体仍包含其占位符。", + "expression_hint_encode": "要重新编码的文本。", + "label_algorithm": "算法", + "algorithm_required": "必须提供算法", + "md5_warning": "MD5 不具备抗碰撞性。仅在供应商合约要求时使用,切勿用于签名。", + "label_encoding": "编码方式", + "encoding_required": "必须提供编码方式", + "encoding_default_hint": "留空时默认使用十六进制。", + "label_secret": "共享密钥", + "secret_required": "必须提供共享密钥", + "secret_too_long": "密钥最多 4,096 个字符", + "secret_hint": "静态加密存储,且不会再次显示。", + "secret_unchanged_hint": "已存储密钥。留空以保留,或输入新值以替换。", + "label_target": "注入到(可选)", + "target_hint": "header:X-Signature 或 query:signature——该值会自动写入其中。留空则改用 {{name}} 占位符。", + "placeholder_target": "header:X-Signature", + "target_invalid": "请使用 header:名称 或 query:名称", + "label_overridable": "允许按请求覆盖", + "overridable_hint": "在该连接器上拥有覆盖权限的提交者可以提供自己的值。", + "overridable_secret_hint": "包含密钥的变量永远不能被覆盖。", + "label_description": "描述", + "placeholder_description": "供应商请求签名", + "description_too_long": "描述最多 512 个字符", + "save_success": "变量已保存", + "save_error": "无法保存变量", + "delete_success": "变量已删除", + "delete_error": "无法删除变量", + "delete_confirm_title": "删除该变量?", + "delete_confirm_body": "引用 {{name}} 的请求将按字面发送该占位符。", + "reorder_error": "无法调整变量顺序" + }, + "canOverrideVariables": "覆盖变量", + "canOverrideVariablesHint": "可为该连接器的可覆盖动态变量提供按请求的值。" }, "editor": { "title": "API Editor", @@ -3287,7 +3379,13 @@ "searchOperation": "搜索操作…", "scheduleFor": "安排稍后执行", "scheduleHint": "留空则在批准后立即执行", - "scheduleInPast": "计划时间必须是将来的时间" + "scheduleInPast": "计划时间必须是将来的时间", + "tabVariables": "变量", + "variablesHint": "该连接器按请求计算值。可在任意位置以 {{name}} 引用。", + "variableOverrides": "覆盖值", + "variableOverridesHint": "为可覆盖的变量提供你自己的值", + "noOverridableVariables": "该连接器的变量均不可按请求覆盖。", + "availableVariables": "可用占位符" }, "requests": { "title": "API Requests", @@ -3339,7 +3437,11 @@ "filterTraceId": "追踪 ID", "filterSpanId": "Span ID", "downloadResponse": "下载完整响应", - "downloadFailed": "无法下载响应" + "downloadFailed": "无法下载响应", + "variableOverrides": "变量覆盖值", + "variableOverridesHint": "提交者为该连接器可覆盖变量提供的值。这些是签名输入;签名、随机数等计算结果永不展示。", + "variableName": "变量", + "variableValue": "值" }, "reviews": { "title": "API Reviews", @@ -3353,7 +3455,9 @@ "risk": "Risk", "subtitle": "批准或拒绝受治理的 API 调用", "searchPlaceholder": "按连接器、路径搜索", - "actions": "操作" + "actions": "操作", + "overrides": "覆盖", + "overridesHint": "该请求覆盖了连接器变量" }, "error": "Something went wrong" }, diff --git a/frontend/src/pages/apigov/ApiConnectorSettingsPage.tsx b/frontend/src/pages/apigov/ApiConnectorSettingsPage.tsx index c789b7d1..73576ac1 100644 --- a/frontend/src/pages/apigov/ApiConnectorSettingsPage.tsx +++ b/frontend/src/pages/apigov/ApiConnectorSettingsPage.tsx @@ -49,6 +49,7 @@ import { KeyValueEditor } from '@/components/apigov/KeyValueEditor'; import { ApiConnectorMaskingTab } from '@/components/apigov/ApiConnectorMaskingTab'; import { ApiConnectorClassificationTab } from '@/components/apigov/ApiConnectorClassificationTab'; import { ApiConnectorPermissionsTab } from '@/components/apigov/ApiConnectorPermissionsTab'; +import { ApiConnectorVariablesTab } from '@/components/apigov/ApiConnectorVariablesTab'; import { pairsToRecord, recordToPairs, @@ -92,6 +93,7 @@ export default function ApiConnectorSettingsPage() { { key: 'schema', label: t('apiGov.settings.tabSchema'), children: }, { key: 'operations', label: t('apiGov.settings.tabOperations'), children: }, { key: 'permissions', label: t('apiGov.settings.tabPermissions'), children: }, + { key: 'variables', label: t('apiGov.settings.tabVariables'), children: }, { key: 'masking', label: t('apiGov.settings.tabMasking'), children: }, { key: 'classification', label: t('apiGov.settings.tabClassification'), children: }, ]} diff --git a/frontend/src/pages/apigov/ApiEditorPage.tsx b/frontend/src/pages/apigov/ApiEditorPage.tsx index a2e2e7b4..73a5ff17 100644 --- a/frontend/src/pages/apigov/ApiEditorPage.tsx +++ b/frontend/src/pages/apigov/ApiEditorPage.tsx @@ -10,6 +10,10 @@ import { submitApiRequest } from '@/api/apiRequests'; import { apiErrorMessage } from '@/utils/apiErrors'; import { showApiError } from '@/utils/showApiError'; import { ApiAuthoringPanel } from '@/components/apigov/ApiAuthoringPanel'; +import { + apiConnectorVariableKeys, + listApiConnectorVariableSummaries, +} from '@/api/apiConnectorVariables'; import { useApiAuthoring, type ApiAuthoringValue } from '@/components/apigov/useApiAuthoring'; import { compositionToSubmit, newComposition } from '@/utils/apiRequestComposition'; @@ -28,6 +32,13 @@ export default function ApiEditorPage() { composition: newComposition(), }); + // AF-613: the submitter-safe projection, so the composer can offer per-request overrides. + const variablesQuery = useQuery({ + queryKey: apiConnectorVariableKeys.summary(connectorId ?? ''), + queryFn: () => listApiConnectorVariableSummaries(connectorId as string), + enabled: !!connectorId, + }); + const connectorsQuery = useQuery({ queryKey: apiConnectorKeys.list({ size: 100 }), queryFn: () => listApiConnectors({ size: 100 }), @@ -85,6 +96,7 @@ export default function ApiEditorPage() { connector={connector} value={value} onChange={setValue} + connectorVariables={variablesQuery.data ?? []} header={