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={