From 5e20ddf8b24be8b0ca750644deb7badb034bddfc Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 25 Jul 2026 22:21:12 +0700 Subject: [PATCH 1/9] feat(relay): promote attribute release to default Signed-off-by: Jeremi Joslin --- .github/workflows/release-candidate.yml | 9 +- crates/registry-relay/CHANGELOG.md | 7 + crates/registry-relay/Cargo.toml | 12 +- crates/registry-relay/Dockerfile | 10 +- crates/registry-relay/README.md | 22 +- crates/registry-relay/deny.toml | 7 +- crates/registry-relay/docs/api.md | 26 +- crates/registry-relay/docs/configuration.md | 9 +- crates/registry-relay/docs/ops.md | 8 +- crates/registry-relay/docs/release-notes.md | 9 + .../openapi/registry-relay.openapi.json | 468 ++++++++++++++++++ .../openapi/registry-relay.reference.yaml | 17 + .../scripts/check_docker_build_contract.py | 17 +- .../security/exposure-manifest.json | 14 +- crates/registry-relay/src/api/admin.rs | 1 + .../src/api/attribute_release.rs | 143 +++--- crates/registry-relay/src/api/openapi.rs | 56 +-- .../src/attribute_release/mod.rs | 103 +++- crates/registry-relay/src/config/mod.rs | 71 +-- crates/registry-relay/src/config/validate.rs | 114 +++-- crates/registry-relay/src/lib.rs | 23 + .../tests/attribute_release_api.rs | 143 +++--- .../tests/attribute_release_feature_flag.rs | 5 +- .../registry-relay/tests/config_entities.rs | 68 ++- .../tests/observability_metrics.rs | 18 + .../tests/protected_cache_control.rs | 3 +- crates/registryctl/CHANGELOG.md | 12 + .../project-authoring/project.schema.json | 11 +- .../src/project_authoring/compiler/relay.rs | 6 - .../src/project_authoring/model.rs | 15 +- .../src/project_authoring/project.rs | 9 - .../src/project_authoring/tests.rs | 7 +- .../nia-attribute-release/README.md | 9 +- .../nia-attribute-release/registry-stack.yaml | 3 - deny.toml | 7 +- .../scripts/relay-release-contract.test.mjs | 70 ++- .../docs/reference/apis/registry-relay.mdx | 7 +- .../src/data/generated/relay-support.json | 33 +- docs/site/src/data/relay-support.yaml | 29 +- release/scripts/build-release-binaries.sh | 3 +- release/scripts/build-release-image.sh | 8 + .../scripts/check-release-image-oci-labels.py | 37 ++ .../scripts/check-release-relay-features.py | 48 +- .../scripts/smoke-release-image-oci-labels.sh | 15 +- .../test_check_release_image_oci_labels.py | 72 +++ .../test_check_release_relay_features.py | 58 ++- release/scripts/test_registry_release.py | 5 + schemas/registry-relay.config.schema.json | 67 +-- 48 files changed, 1417 insertions(+), 497 deletions(-) diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 5778ca169..553037c45 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -856,11 +856,18 @@ jobs: printf '%s\n' "${grype_binary_sha256}" > dist/candidate/dist/grype/grype-binary.sha256 for name in registry-notary registry-relay; do digest_ref="$(cat "inputs/build-a/dist/images/${name}.digest")" + expected_label_args=() + if [[ "${name}" == "registry-relay" ]]; then + expected_label_args+=( + --expected-label "org.registrystack.registry-relay.features=attribute-release,crosswalk-runtime" + ) + fi python3 release/scripts/check-release-image-oci-labels.py \ "${digest_ref}" \ --source "https://github.com/${GITHUB_REPOSITORY}" \ --revision "${{ needs.validate.outputs.source_sha }}" \ - --version "${{ needs.validate.outputs.version }}" + --version "${{ needs.validate.outputs.version }}" \ + "${expected_label_args[@]}" syft "${digest_ref}" \ -o "spdx-json=dist/candidate/dist/sbom/${name}.spdx.json" \ -o "syft-json=dist/candidate/dist/sbom/${name}.syft.json" diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index a69e89130..2fb91ea27 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- BREAKING: Promote governed attribute release to the stable default and + canonical release feature set. Require purpose, exact release-scope, and + subject-type bindings; remove inert pre-1.0 configuration fields; evaluate + release expressions only over redacted rows; prevent profile enumeration; + disable response storage; and publish compiled feature evidence through the + admin capabilities document and Relay image metadata. + ## 0.13.0 - 2026-07-25 - BREAKING: Remove the inert six-field `provenance.consent` member from the diff --git a/crates/registry-relay/Cargo.toml b/crates/registry-relay/Cargo.toml index 8e1a78c91..92396ab0a 100644 --- a/crates/registry-relay/Cargo.toml +++ b/crates/registry-relay/Cargo.toml @@ -14,10 +14,10 @@ workspace = true ignored = ["humantime-serde"] [features] -# The 1.0 default build keeps the beta attribute-release surface out of the -# shipped API. Opt in with `--features attribute-release` to compile the -# CEL-backed adapter, routes, config surface, and observability buckets. -default = [] +# The 1.0 default build includes the stable attribute-release surface. Release +# recipes still select the exact canonical features explicitly so changing this +# developer default cannot silently expand a published binary. +default = ["attribute-release"] attribute-release = ["crosswalk-runtime"] crosswalk-runtime = ["dep:crosswalk-core"] standards-cel-mapping = ["crosswalk-runtime"] @@ -79,8 +79,8 @@ serde_json = { version = "1" } schemars.workspace = true rhai.workspace = true crosswalk-functions = { workspace = true, features = ["date", "redaction"] } -# Optional Crosswalk CEL mapping support. Disabled by default so the core VC -# path does not pull in the mapping dependency graph. +# Crosswalk CEL support for canonical attribute release and optional mapping +# adapters. crosswalk-core = { workspace = true, optional = true } jsonschema = { version = "0.18", optional = true } # Pure Rust YAML parser used instead of the previous unmaintained YAML stack. diff --git a/crates/registry-relay/Dockerfile b/crates/registry-relay/Dockerfile index c719b8c8a..5dfe7c35e 100644 --- a/crates/registry-relay/Dockerfile +++ b/crates/registry-relay/Dockerfile @@ -1,7 +1,10 @@ # syntax=docker/dockerfile:1.7 +ARG REGISTRY_RELAY_FEATURES="attribute-release,crosswalk-runtime" + # Keep the tag for humans and the digest for reproducible pulls. FROM rust:1.95-trixie@sha256:f49565f188ee00bc2a18dd418183f2c5f23ef7d6e691890517ed341a598f67c3 AS builder +ARG REGISTRY_RELAY_FEATURES WORKDIR /workspace/registry_relay COPY Cargo.toml Cargo.lock ./ @@ -15,14 +18,13 @@ COPY benches ./benches COPY resources ./resources COPY src ./src -ARG REGISTRY_RELAY_FEATURES="" RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/workspace/registry_relay/target \ find src benches resources -type f -exec touch {} + && \ if [ -n "$REGISTRY_RELAY_FEATURES" ]; then \ - cargo build --release --locked --features "$REGISTRY_RELAY_FEATURES"; \ + cargo build --release --locked --no-default-features --features "$REGISTRY_RELAY_FEATURES"; \ else \ - cargo build --release --locked; \ + cargo build --release --locked --no-default-features; \ fi && \ cp /workspace/registry_relay/target/release/registry-relay /usr/local/bin/registry-relay && \ cp /workspace/registry_relay/target/release/registry-relay-rhai-worker /usr/local/bin/registry-relay-rhai-worker && \ @@ -35,11 +37,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ # Distroless cc keeps glibc and CA certificates while dropping shell/package tools. FROM gcr.io/distroless/cc-debian13:nonroot@sha256:d97bc0a941b8d4be647dc0ee75b264ddbb772f1ac5ba690a4309c00723b23775 AS runtime +ARG REGISTRY_RELAY_FEATURES COPY --from=builder --chown=65532:65532 /workspace/runtime-root/ / COPY --from=builder /usr/local/bin/registry-relay /usr/local/bin/registry-relay COPY --from=builder /usr/local/bin/registry-relay-rhai-worker /usr/local/bin/registry-relay-rhai-worker COPY LICENSE /licenses/registry-relay/LICENSE +LABEL org.registrystack.registry-relay.features="${REGISTRY_RELAY_FEATURES}" WORKDIR /var/lib/registry-relay diff --git a/crates/registry-relay/README.md b/crates/registry-relay/README.md index df12ba869..1c9d9a8c3 100644 --- a/crates/registry-relay/README.md +++ b/crates/registry-relay/README.md @@ -23,15 +23,16 @@ Standards integrations such as DCAT-AP, OGC API Records, OGC API Features, Regis ## 1.0 Support Roster -The canonical 1.0 release has no optional Relay Cargo features enabled. Stable -support covers OpenAPI 3.x and RFC 9457 errors; RFC 9727 and portable DCAT, -DCAT-AP, BRegDCAT-AP, JSON-LD, SHACL, JSON Schema, ODRL, and link-free OGC -Records metadata; CSV and XLSX source input; and JSON aggregate output. +The canonical 1.0 release enables the `attribute-release` and +`crosswalk-runtime` Relay Cargo features. Stable support covers governed +attribute release; OpenAPI 3.x and RFC 9457 errors; RFC 9727 and portable +DCAT, DCAT-AP, BRegDCAT-AP, JSON-LD, SHACL, JSON Schema, ODRL, and link-free +OGC Records metadata; CSV and XLSX source input; and JSON aggregate output. CSV, XLSX, and Parquet are source decoders. Aggregate output supports JSON, CSV, and SDMX-JSON. The live OGC API Records adapter, OGC API Features, OGC API EDR, SP DCI routes, standards-CEL mapping, CSV and SDMX-JSON aggregate output, -attribute release, and Parquet source input are experimental and feature-frozen. +and Parquet source input are experimental and feature-frozen. Experimental surfaces are outside the 1.0 compatibility promise. Feature-gated source and all-feature tests remain available. Non-feature-gated experimental formats remain shipped but unstable to avoid breaking existing configurations. @@ -105,7 +106,16 @@ registry-stack root. scripts/build-image.sh registry-relay:local ``` -The production image is distroless, non-root, and built with no optional Cargo features; standards-enabled images opt in through `REGISTRY_RELAY_FEATURES`. Build steps, sibling-checkout requirements, and promotion gates are in [docs/ops.md](docs/ops.md#build-and-release); image publication, tagging, and signing policy are in [docs/security-assurance.md](docs/security-assurance.md). Release images publish to `ghcr.io/registrystack/registry-relay` from stable `vX.Y.Z` tags and `registry-stack-technical-preview-` tags; consume release tags or digests, not `latest`, for rollback guarantees. `Dockerfile.demo` is demo-only and is not release evidence. +The production image is distroless, non-root, and built with the canonical +`attribute-release,crosswalk-runtime` feature set. A custom +`REGISTRY_RELAY_FEATURES` value replaces that exact set. Build steps, +sibling-checkout requirements, and promotion gates are in +[docs/ops.md](docs/ops.md#build-and-release); image publication, tagging, and +signing policy are in [docs/security-assurance.md](docs/security-assurance.md). +Release images publish to `ghcr.io/registrystack/registry-relay` from stable +`vX.Y.Z` tags and `registry-stack-technical-preview-` tags; +consume release tags or digests, not `latest`, for rollback guarantees. +`Dockerfile.demo` is demo-only and is not release evidence. ## Operating With Registry Notary diff --git a/crates/registry-relay/deny.toml b/crates/registry-relay/deny.toml index ae175bcf4..8fb5b0301 100644 --- a/crates/registry-relay/deny.toml +++ b/crates/registry-relay/deny.toml @@ -15,9 +15,10 @@ ignore = [ # through Crosswalk -> phonenumber -> postcard -> heapless 0.7. Phonenumber # uses postcard at build time and runtime, but heapless selects # `atomic-polyfill` only for AVR, riscv32i/riscv32imc, thumbv6m, and - # xtensa-esp32s2, not Relay's supported service targets. Review when the - # chain removes it, before supporting an affected embedded target, or - # before making CEL mapping part of a hardened default runtime. + # xtensa-esp32s2, not Relay's supported x86_64/aarch64 Linux or x86_64 + # macOS service targets. Reviewed 2026-07-25 for the canonical + # attribute-release runtime. Review when the chain removes it, before + # supporting an affected embedded target, or if the advisory scope changes. "RUSTSEC-2023-0089", # RUSTSEC-2024-0436: `paste` is an unmaintained build-time procedural macro # pulled in by Relay's DataFusion and Parquet graph. It does not remain in diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index a69185d55..f96774c95 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -16,7 +16,7 @@ The public URL space is structured as follows: - `/v1/datasets/{dataset_id}/entities/{entity}/...` and related aggregate, measure, and dimension routes are the entity-oriented data-plane surface. - `/v1/consultations/{profile_id}` and its `/execute` subresource expose one active, purpose-aware consultation contract to the configured authorized workload. Callers pin the exact `contract_hash`; route versions are not part of the contract. -- `/v1/attribute-releases` and `/v1/attribute-releases/{profile_id}/versions/{version}/resolve` (feature: `attribute-release`, off by default for 1.0) resolve governed identity attribute-release profiles to minimized claim bundles. +- `/v1/attribute-releases` and `/v1/attribute-releases/{profile_id}/versions/{version}/resolve` (default feature: `attribute-release`) resolve governed identity attribute-release profiles to minimized claim bundles. - `/metadata/*` is the standards-facing metadata surface: catalog, DCAT, SHACL, policies, evidence offerings, and dataset/entity descriptors. - `/.well-known/api-catalog` is the public well-known discovery entry point. - `/ogc/v1/*` (feature: `ogcapi-features`) exposes spatial entities as OGC API Features collections. @@ -423,8 +423,8 @@ Aggregate JSON results use `observations` for rows and `structure` for dimension `registry-relay` can resolve a governed identity attribute-release profile: an exactly-one-subject lookup that maps configured source fields (or CEL expressions) into a minimized, OIDC/UserInfo-style claim bundle for a named -profile. This beta surface is compiled only when Relay is built with the -off-by-default `attribute-release` feature. +profile. This stable surface is part of the default and canonical release +builds through the `attribute-release` feature. ```text GET /v1/attribute-releases @@ -441,14 +441,13 @@ profile's `release_scope`. The response never includes source internals one subject against the named `(profile_id, version)` pair, which is globally unique and has no "latest" alias. Each profile declares its own `release_scope`, a dataset-bound scope that must differ from the entity's -`read_scope`; the scope suffix convention for this capability is +`read_scope`; the required scope for this capability is `:identity_release`, one of the scope levels an API key can be granted alongside `metadata`, `aggregate`, `rows`, `verify`, and -`evidence_verification`. A profile may also declare a `purpose`: when it does, -the request's `Data-Purpose` header must equal it, or the gateway returns +`evidence_verification`. Every profile declares a `purpose`. The request's +`Data-Purpose` header must equal it, or the gateway returns `400 auth.purpose_required` (header absent) or `403 auth.purpose_denied` -(header present but mismatched), before any source read. A profile that omits -`purpose` carries no such gate. +(header present but mismatched), before any source read. Request body: @@ -463,7 +462,7 @@ Request body: explicit empty list is rejected with `400 filter.invalid_value`; a name outside the profile's configured claims is denied. `subject.value` accepts only a non-blank scalar (string, number, or boolean); `subject.id_type` must -match the profile's configured type when one is set. Either failure returns +match the profile's configured type. Either failure returns `400 release.subject_invalid`. Successful response (`200`): @@ -491,15 +490,14 @@ denied": | Condition | Code | Status | | --- | --- | --- | -| Unknown `(profile_id, version)` | `release.profile_not_found` | 404 | +| Unknown `(profile_id, version)`, or a known profile outside the caller's scope | `release.profile_not_found` | 404 | | Invalid subject id_type or value | `release.subject_invalid` | 400 | | No matching row, more than one matching row, a false release-condition predicate, a required claim unavailable, or a requested claim name outside the profile's configured set | `release.subject_denied` | 403 | | Backing source unavailable | `release.source_unavailable` | 503 | -A successful release is cacheable only when the profile sets -`response.max_age_seconds`, which yields `private, max-age=N`; the default is -`private, no-store`. Every response carries `Vary: Authorization`. Denials are -always `private, no-store`, regardless of the profile's caching setting. +Every response is `private, no-store` and carries `Vary: Authorization`. +Registry Relay does not make a `POST` release response reusable through an +incomplete cache-control-only contract. ## Problem details diff --git a/crates/registry-relay/docs/configuration.md b/crates/registry-relay/docs/configuration.md index 942ff63df..58b53d124 100644 --- a/crates/registry-relay/docs/configuration.md +++ b/crates/registry-relay/docs/configuration.md @@ -329,23 +329,18 @@ datasets[].entities[].attribute_release_profiles[].claims[].locale datasets[].entities[].attribute_release_profiles[].claims[].name datasets[].entities[].attribute_release_profiles[].claims[].required datasets[].entities[].attribute_release_profiles[].claims[].sensitivity -datasets[].entities[].attribute_release_profiles[].claims[].shareable datasets[].entities[].attribute_release_profiles[].claims[].source_field datasets[].entities[].attribute_release_profiles[].description datasets[].entities[].attribute_release_profiles[].id datasets[].entities[].attribute_release_profiles[].purpose datasets[].entities[].attribute_release_profiles[].release_conditions -datasets[].entities[].attribute_release_profiles[].release_conditions.denied_code datasets[].entities[].attribute_release_profiles[].release_conditions.expression datasets[].entities[].attribute_release_profiles[].release_conditions.expression.cel datasets[].entities[].attribute_release_profiles[].release_scope datasets[].entities[].attribute_release_profiles[].response datasets[].entities[].attribute_release_profiles[].response.include_source_metadata -datasets[].entities[].attribute_release_profiles[].response.max_age_seconds datasets[].entities[].attribute_release_profiles[].subject -datasets[].entities[].attribute_release_profiles[].subject.cardinality datasets[].entities[].attribute_release_profiles[].subject.id_type -datasets[].entities[].attribute_release_profiles[].subject.input datasets[].entities[].attribute_release_profiles[].subject.source_field datasets[].entities[].attribute_release_profiles[].title datasets[].entities[].attribute_release_profiles[].version @@ -1230,8 +1225,8 @@ audit: Registry Relay uses this secret to pseudonymize sensitive audit handles. Values for configured sensitive fields, record primary keys, table identifiers, and -attribute-release subject identifiers, when the off-by-default feature is -enabled, are written as stable audit hashes instead of raw strings. This gives +attribute-release subject identifiers are written as stable audit hashes +instead of raw strings. This gives an auditor a way to see that the same subject or source was accessed more than once without storing the underlying person identifier, address, date of birth, or table id in the audit sink. diff --git a/crates/registry-relay/docs/ops.md b/crates/registry-relay/docs/ops.md index 852786159..0946526ed 100644 --- a/crates/registry-relay/docs/ops.md +++ b/crates/registry-relay/docs/ops.md @@ -197,11 +197,13 @@ checkout at the reviewed commit. Set `REGISTRY_RELAY_ALLOW_UNPINNED_LOCAL_CONTEXTS=1` only for local development builds that will not be published. -The base image is built with no optional Cargo features. Standards-enabled -release or lab images must opt in explicitly: +The base image is built with the canonical +`attribute-release,crosswalk-runtime` feature set. A custom +`REGISTRY_RELAY_FEATURES` value replaces that set, so standards-enabled +release or lab images must retain the canonical features explicitly: ```sh -REGISTRY_RELAY_FEATURES=spdci-api-standards,standards-cel-mapping,ogcapi-edr \ +REGISTRY_RELAY_FEATURES=attribute-release,crosswalk-runtime,spdci-api-standards,standards-cel-mapping,ogcapi-edr \ scripts/build-image.sh registry-relay:-standards ``` diff --git a/crates/registry-relay/docs/release-notes.md b/crates/registry-relay/docs/release-notes.md index 99af026ea..0b259a889 100644 --- a/crates/registry-relay/docs/release-notes.md +++ b/crates/registry-relay/docs/release-notes.md @@ -2,6 +2,15 @@ ## Unreleased +- BREAKING: Attribute release is stable and enabled in the default and + canonical release builds. Profiles now require a purpose, the exact + `:identity_release` scope, and a subject `id_type`. Strict + configuration parsing rejects the unused subject input and cardinality, + claim shareability, denial-code, and response cache-age fields. Release + predicates and computed claims evaluate only the redacted source row, every + response is `private, no-store`, and unauthorized profile lookups are + indistinguishable from unknown profiles. + ## 0.13.0 - BREAKING: Relay consultation results remove the inert diff --git a/crates/registry-relay/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index 169377cb6..0d56a5712 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -1172,6 +1172,199 @@ "description": "RFC 9727 API catalog linkset document.", "type": "object" }, + "AttributeReleaseProfile": { + "additionalProperties": false, + "description": "A governed identity attribute-release profile. Identifies the release scope, accepted subject id types, and the claim names that will be returned on a successful resolve.", + "properties": { + "accepted_subject_id_types": { + "description": "Subject identifier types accepted by this profile.", + "items": { + "type": "string" + }, + "type": "array" + }, + "claim_names": { + "description": "Names of all claims that may be returned by this profile.", + "items": { + "type": "string" + }, + "type": "array" + }, + "description": { + "description": "Human-readable profile description.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Profile identifier, lower-snake or lower-kebab. Globally unique with `version`.", + "type": "string" + }, + "purpose": { + "description": "Data-purpose token or IRI this profile is bound to.", + "type": "string" + }, + "release_scope": { + "description": "Dataset-bound scope a caller must hold to invoke this release. Distinct from the entity read scope.", + "type": "string" + }, + "required_claims": { + "description": "Names of claims that must be present in any successful response.", + "items": { + "type": "string" + }, + "type": "array" + }, + "response_media_type": { + "description": "Media type of the resolve response. Always `application/json` in v1.", + "examples": [ + "application/json" + ], + "type": "string" + }, + "title": { + "description": "Human-readable profile title.", + "type": [ + "string", + "null" + ] + }, + "version": { + "description": "Profile version. Globally unique with `id`.", + "type": "string" + } + }, + "required": [ + "id", + "version", + "purpose", + "release_scope", + "claim_names", + "required_claims", + "accepted_subject_id_types", + "response_media_type" + ], + "type": "object" + }, + "AttributeReleaseProfileList": { + "additionalProperties": false, + "description": "List of attribute release profiles visible to the calling principal.", + "properties": { + "profiles": { + "items": { + "$ref": "#/components/schemas/AttributeReleaseProfile" + }, + "type": "array" + } + }, + "required": [ + "profiles" + ], + "type": "object" + }, + "AttributeReleaseResolveRequest": { + "additionalProperties": false, + "description": "Request body for resolving an attribute release profile against one subject.", + "properties": { + "claims": { + "description": "Optional subset of claim names to return. Absent means the profile default set; an empty array is rejected (400); any unknown claim name is denied.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "subject": { + "additionalProperties": false, + "description": "The subject to look up.", + "properties": { + "id_type": { + "description": "Subject identifier type (e.g. `national_id`, `passport`).", + "type": "string" + }, + "value": { + "description": "Subject identifier value. Never logged or echoed in responses.", + "type": "string" + } + }, + "required": [ + "id_type", + "value" + ], + "type": "object" + } + }, + "required": [ + "subject" + ], + "type": "object" + }, + "AttributeReleaseResolveResponse": { + "additionalProperties": false, + "description": "Resolved attribute release claim bundle. Contains only the approved, minimised claims for the matched subject. Never includes raw source rows, subject identifiers outside released claims, or private source internals.", + "properties": { + "claims": { + "additionalProperties": true, + "description": "Released claim bundle. Keys are claim names; values are the projected or computed claim values.", + "type": "object" + }, + "profile_id": { + "description": "Identifier of the release profile used to resolve this response.", + "type": "string" + }, + "profile_version": { + "description": "Version of the release profile used to resolve this response.", + "type": "string" + }, + "source": { + "additionalProperties": false, + "description": "Profile-sourced metadata about the release. Present only when the profile sets `response.include_source_metadata: true`; a minimising profile omits it entirely. Contains only metadata derived from the profile configuration — never private source table ids, paths, or secrets.", + "properties": { + "cardinality": { + "description": "Stable attribute release always requires exactly one subject.", + "enum": [ + "one" + ], + "type": "string" + }, + "checked_at": { + "description": "ISO 8601 timestamp at which the source was checked.", + "format": "date-time", + "type": "string" + }, + "dataset": { + "description": "Dataset identifier for the backing source.", + "type": "string" + }, + "entity": { + "description": "Entity name for the backing source.", + "type": "string" + }, + "subject_id_type": { + "description": "Subject identifier type used in the lookup.", + "type": "string" + } + }, + "required": [ + "dataset", + "entity", + "subject_id_type", + "cardinality", + "checked_at" + ], + "type": "object" + } + }, + "required": [ + "profile_id", + "profile_version", + "claims" + ], + "type": "object" + }, "ConsultationExecuteRequest": { "additionalProperties": false, "description": "The exact consultation-v1 request envelope. Contract mismatch fails before source access. The complete encoded request body is limited to 8 KiB.", @@ -5080,6 +5273,271 @@ ] } }, + "/v1/attribute-releases": { + "get": { + "description": "Returns all attribute release profiles visible to the calling principal. Authenticated-only: the response includes release_scope strings, which are never exposed anonymously.", + "operationId": "list_attribute_release_profiles", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttributeReleaseProfileList" + } + } + }, + "description": "Attribute release profile list." + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Missing or invalid bearer credential." + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Authenticated principal lacks the scope required for this operation." + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Problem Details error response." + } + }, + "summary": "List attribute release profiles", + "tags": [ + "Attribute Releases" + ] + } + }, + "/v1/attribute-releases/{profile_id}/versions/{version}/resolve": { + "post": { + "description": "Resolves a single subject against a named release profile and returns the approved claim bundle. The subject identifier is sent in the request body so it does not appear in access logs.", + "operationId": "resolve_attribute_release", + "parameters": [ + { + "description": "Attribute release profile identifier.", + "in": "path", + "name": "profile_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Attribute release profile version.", + "in": "path", + "name": "version", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:legal_basis:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-trust-legal-basis", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:consent:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-trust-consent", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:assurance:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-trust-assurance", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:jurisdiction:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-trust-jurisdiction", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:subject_ref:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-subject-ref", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:relationship:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-relationship", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:on_behalf_of:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-on-behalf-of", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:requested_credential_format:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-credential-format", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:source_observed_at_unix_seconds:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-source-observed-at-unix-seconds", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:source_observed_age_seconds:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", + "in": "header", + "name": "x-registry-source-observed-age-seconds", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttributeReleaseResolveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AttributeReleaseResolveResponse" + } + } + }, + "description": "Resolved attribute release claim bundle." + }, + "400": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Invalid request: malformed body, unknown id_type, empty claims list, or unsupported media type." + }, + "401": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Missing or invalid bearer credential." + }, + "403": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Subject denied: not found, ambiguous, release condition not met, or required claim unavailable. The response does not distinguish these cases." + }, + "404": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Profile not found or not visible to the authenticated principal. Does not confirm whether the profile ever existed." + }, + "503": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Source unavailable." + }, + "default": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Problem Details error response." + } + }, + "summary": "Resolve attribute release", + "tags": [ + "Attribute Releases" + ] + } + }, "/v1/consultations/{profile_id}": { "get": { "description": "Returns the complete hash-pinned public contract for the one active contract behind this profile id. The response is protected and visible only to the configured authorized OIDC workload with the profile's exact required scope.", @@ -7579,6 +8037,10 @@ "description": "Catalog discovery: dataset listing, dataset metadata, DCAT-AP export.", "name": "Catalog" }, + { + "description": "Projection-limited, exactly-one-subject identity attribute release; every profile is purpose-bound. Returns only the approved claim bundle for a named release profile; never a raw registry row.", + "name": "Attribute Releases" + }, { "description": "Dataset-scoped aggregate discovery and execution for `social_registry`.", "name": "social_registry / aggregates", @@ -7616,6 +8078,12 @@ "Catalog" ] }, + { + "name": "Attribute Releases", + "tags": [ + "Attribute Releases" + ] + }, { "name": "Social Registry", "tags": [ diff --git a/crates/registry-relay/openapi/registry-relay.reference.yaml b/crates/registry-relay/openapi/registry-relay.reference.yaml index 0dc25a901..b93804a84 100644 --- a/crates/registry-relay/openapi/registry-relay.reference.yaml +++ b/crates/registry-relay/openapi/registry-relay.reference.yaml @@ -161,6 +161,23 @@ datasets: - field: household_id ops: [eq] allowed_expansions: [household] + attribute_release_profiles: + - id: individual_identity + version: v1 + title: Individual identity + description: Minimal identity profile used for the canonical OpenAPI contract. + purpose: identity_verification + release_scope: social_registry:identity_release + subject: + source_field: id + id_type: individual_id + release_conditions: + expression: + cel: source.age >= 0 + claims: + - name: individual_id + source_field: id + required: true audit: sink: stdout diff --git a/crates/registry-relay/scripts/check_docker_build_contract.py b/crates/registry-relay/scripts/check_docker_build_contract.py index 3c4555df2..a0a822803 100755 --- a/crates/registry-relay/scripts/check_docker_build_contract.py +++ b/crates/registry-relay/scripts/check_docker_build_contract.py @@ -80,14 +80,14 @@ def main() -> int: failures.extend( require( dockerfile, - 'ARG REGISTRY_RELAY_FEATURES=""', - "empty-by-default feature build arg", + 'ARG REGISTRY_RELAY_FEATURES="attribute-release,crosswalk-runtime"', + "canonical feature build arg", ) ) failures.extend( require( dockerfile, - 'cargo build --release --locked --features "$REGISTRY_RELAY_FEATURES"', + 'cargo build --release --locked --no-default-features --features "$REGISTRY_RELAY_FEATURES"', "feature-enabled cargo build path", ) ) @@ -101,8 +101,15 @@ def main() -> int: failures.extend( require( dockerfile, - "cargo build --release --locked", - "default cargo build path", + "cargo build --release --locked --no-default-features", + "explicit minimal cargo build path", + ) + ) + failures.extend( + require( + dockerfile, + 'LABEL org.registrystack.registry-relay.features="${REGISTRY_RELAY_FEATURES}"', + "compiled feature metadata label", ) ) failures.extend( diff --git a/crates/registry-relay/security/exposure-manifest.json b/crates/registry-relay/security/exposure-manifest.json index 14c71de58..96e64b52b 100644 --- a/crates/registry-relay/security/exposure-manifest.json +++ b/crates/registry-relay/security/exposure-manifest.json @@ -1213,13 +1213,13 @@ ], "rate_limit": null, "audit": "required", - "openapi": false, - "stability": "experimental", + "openapi": true, + "stability": "stable", "data_classification": "personal", - "notes": "Off-by-default experimental, feature-frozen surface outside the 1.0 compatibility promise. Governed identity attribute release is purpose-bound, projection-limited, and exactly-one-subject.", + "notes": "Stable canonical surface. Governed identity attribute release is purpose-bound, projection-limited, exactly-one-subject, and non-storable.", "source": "manual", "enforcement_tests": [ - "tests/attribute_release_api.rs::resolve_without_release_scope_is_denied_before_read", + "tests/attribute_release_api.rs::resolve_without_release_scope_is_hidden_like_unknown_profile", "tests/attribute_release_api.rs::resolve_missing_purpose_denies_before_read" ], "waiver": null @@ -1237,10 +1237,10 @@ ], "rate_limit": null, "audit": "required", - "openapi": false, - "stability": "experimental", + "openapi": true, + "stability": "stable", "data_classification": "metadata", - "notes": "Off-by-default experimental, feature-frozen surface outside the 1.0 compatibility promise. Authenticated discovery lists visible profiles without exposing source internals.", + "notes": "Stable canonical surface. Authenticated discovery lists visible profiles without exposing source internals.", "source": "manual", "enforcement_tests": [ "tests/attribute_release_api.rs::discovery_hides_profiles_without_release_scope", diff --git a/crates/registry-relay/src/api/admin.rs b/crates/registry-relay/src/api/admin.rs index fdd05c0da..dc2a2156e 100644 --- a/crates/registry-relay/src/api/admin.rs +++ b/crates/registry-relay/src/api/admin.rs @@ -114,6 +114,7 @@ async fn capabilities(runtime: RuntimeSnapshot, _ops: OpsReadPrincipal) -> Respo "schema": "registry.admin.capabilities.v1", "product": "registry-relay", "admin_api_version": "v1", + "cargo_features": crate::compiled_cargo_features(), "supported_posture_tiers": ["default", "restricted"], "config": { "verify": { diff --git a/crates/registry-relay/src/api/attribute_release.rs b/crates/registry-relay/src/api/attribute_release.rs index 26d92b891..d69836adf 100644 --- a/crates/registry-relay/src/api/attribute_release.rs +++ b/crates/registry-relay/src/api/attribute_release.rs @@ -3,10 +3,9 @@ //! //! A release profile is a projection-limited, exactly-one-subject lookup that //! returns only the attributes approved for a named profile, mapped into -//! OIDC/UserInfo-style claims. A profile is *optionally* purpose-bound: when it -//! declares a `purpose`, the request's `data-purpose` header must equal it -//! before any release; a profile that omits `purpose` carries no such gate. The -//! resolve handler +//! OIDC/UserInfo-style claims. Every profile is purpose-bound: the request's +//! `data-purpose` header must equal the configured purpose before any release. +//! The resolve handler //! never returns a raw registry row: the response body is built field-by-field //! from profile metadata and the projected claim set, so no source field absent //! from the profile, no raw subject value (outside a released claim), and no @@ -114,17 +113,35 @@ async fn resolve( Json(body): Json, ) -> Response { let RouteDeps { runtime, principal } = deps; + let Some(principal) = principal else { + return with_release_cache_headers( + Error::from(AuthError::MissingCredential).into_response(), + ); + }; let route = match RouteState::resolve(&runtime, &profile_id, &version) { Ok(route) => route, - // `profile_not_found` renders as a generic 404 that does not confirm - // enumeration; it carries no audit context because no gate ran. It is - // still marked non-cacheable so a 404 is never stored. - Err(error) => return with_release_cache_headers(error.into_response(), None), + // Profile lookup runs only after authentication so anonymous callers + // cannot distinguish a configured profile from an unknown one. + Err(error) => return with_release_cache_headers(error.into_response()), }; - // Only a successful release honours the profile's caching opt-in; every - // denial is forced to `no-store` below by passing `None`. - let success_max_age = route.profile.response.max_age_seconds; - let result = run_resolve(&runtime, &route, &headers, principal, body).await; + if !principal.scopes.contains(&route.profile.release_scope) { + // Discovery hides profiles whose release scope the principal lacks. + // Resolve follows the same anti-enumeration rule: an authenticated + // caller sees the same 404 for an unknown profile and for a known + // profile it cannot discover. The audit retains the real scope-denial + // outcome. + let response = Error::from(ReleaseError::ProfileNotFound).into_response(); + let response = with_audit_context( + response, + &route, + ResolveAudit { + internal_outcome: Some("auth.scope_denied".to_string()), + ..ResolveAudit::default() + }, + ); + return with_release_cache_headers(response); + } + let result = run_resolve(&runtime, &route, &headers, Some(principal), body).await; match result { Ok(success) => { let response = with_audit_context( @@ -141,12 +158,12 @@ async fn resolve( pdp_trust_provenance: BTreeSet::new(), }, ); - with_release_cache_headers(response, success_max_age) + with_release_cache_headers(response) } Err(error) => { let response = error.error.into_response(); let response = with_audit_context(response, &route, error.audit); - with_release_cache_headers(response, None) + with_release_cache_headers(response) } } } @@ -252,8 +269,8 @@ async fn run_resolve( require_release_scope(principal_ref, &route.profile.release_scope)?; // 3 + 4: purpose + ODRL policy enforced atomically. `DeferredOutput` - // because redaction applies to the projected claim bundle, not raw entity - // fields. Denials return before the source read. + // because this route applies governed redaction before its release predicate + // and claim projection. Denials return before the source read. let governed = require_governed_read_access( runtime, route.dataset_id.as_str(), @@ -269,26 +286,18 @@ async fn run_resolve( )?; let pdp_audit = governed.audit.clone(); - // 4b: profile-level purpose binding. When a profile declares a `purpose`, the - // `data-purpose` header must be present and equal it — enforced here, before - // the source read, independent of whether the backing entity governs - // purposes (`require_governed_read_access` only checks purpose when the entity - // does). When the entity also governs, this is an additional equality - // constraint on the same header. A profile with no purpose keeps the prior - // behaviour. Missing header ⇒ 400 auth.purpose_required; mismatch ⇒ 403 - // auth.purpose_denied — surfaced like the entity-governed purpose denials, - // not collapsed into the subject-denied outcome. - if let Some(profile_purpose) = route.profile.purpose.as_deref() { - match purpose_header_value(headers) { - Some(value) if value == profile_purpose => {} - Some(_) => { - return Err(ResolveRunError::from(Error::from(AuthError::PurposeDenied))); - } - None => { - return Err(ResolveRunError::from(Error::from( - AuthError::PurposeRequired, - ))); - } + // 4b: profile-level purpose binding. The `data-purpose` header must be + // present and equal the profile purpose before the source read, independent + // of whether the backing entity also governs purposes. + match purpose_header_value(headers) { + Some(value) if value == route.profile.purpose => {} + Some(_) => { + return Err(ResolveRunError::from(Error::from(AuthError::PurposeDenied))); + } + None => { + return Err(ResolveRunError::from(Error::from( + AuthError::PurposeRequired, + ))); } } @@ -351,12 +360,17 @@ async fn run_resolve( } }; - // 8: release-condition predicate (CEL). A false predicate, or any evaluation - // failure, fails closed to SubjectReleaseDenied. + // Governed redaction applies before every operator-authored CEL evaluation. + // A release predicate or computed claim cannot use a field the PDP removed + // to reconstruct even a boolean about that field. + let projection_row = redact_row(&row, &governed.redaction_fields); + + // 8: release-condition predicate (CEL). A false predicate, a reference to a + // redacted field, or any evaluation failure fails closed. if let Some(conditions) = route.profile.release_conditions.as_ref() { let allowed = route .evaluator - .evaluate_release_predicate(&conditions.expression.cel, &row) + .evaluate_release_predicate(&conditions.expression.cel, &projection_row) .unwrap_or(false); if !allowed { return Err(ResolveRunError::release( @@ -374,14 +388,8 @@ async fn run_resolve( // ClaimUnavailable; optional missing ⇒ omit; a claim whose source field is // dropped by governed redaction is treated as unavailable. // - // Governed redaction is field-layer: `claim_is_redacted` gates *direct* - // claims, but a computed (CEL) claim reads the row directly and would - // otherwise see a redacted field. Project claims over a row with the - // redacted fields removed so a CEL reference to one resolves to null/error - // and the claim fails closed — closing the redaction-bypass path for - // computed claims. The release predicate above runs over the full row on - // purpose: it is a disclosure gate whose boolean result reveals no value. - let projection_row = redact_row(&row, &governed.redaction_fields); + // Governed redaction is field-layer: `claim_is_redacted` gates direct + // claims, while computed claims evaluate over the already-redacted row. let mut released = Map::new(); for claim in &requested { if claim_is_redacted(claim, &governed.redaction_fields) { @@ -450,8 +458,8 @@ async fn run_resolve( } /// Validate the subject id_type and value before any source read. A mismatched -/// id_type, or (for an unpinned profile) a blank id_type, or a non-scalar/blank -/// value fails closed to `release.subject_invalid` (400) — a request-shape error +/// id_type or a non-scalar/blank value fails closed to +/// `release.subject_invalid` (400) — a request-shape error /// distinct from the collapsed `release.subject_denied`. Unlike subject /// existence, id_type/value validity reveals nothing about the backing registry, /// so surfacing it as a distinct, diagnosable code is safe. @@ -459,11 +467,7 @@ fn validate_subject( profile: &AttributeReleaseProfile, subject: &ResolveSubject, ) -> Result { - if let Some(expected) = profile.subject.id_type.as_deref() { - if subject.id_type != expected { - return Err(ReleaseError::SubjectInvalid.into()); - } - } else if subject.id_type.trim().is_empty() { + if subject.id_type != profile.subject.id_type { return Err(ReleaseError::SubjectInvalid.into()); } match scalar_subject_value(&subject.value) { @@ -663,15 +667,13 @@ fn discovery_profile(profile: &AttributeReleaseProfile) -> Value { .filter(|claim| claim.required) .map(|claim| claim.name.as_str()) .collect(); - let accepted_subject_id_types: Vec<&str> = - profile.subject.id_type.as_deref().into_iter().collect(); json!({ "id": profile.id, "version": profile.version, "title": profile.title, "description": profile.description, "purpose": profile.purpose, - "accepted_subject_id_types": accepted_subject_id_types, + "accepted_subject_id_types": [profile.subject.id_type.as_str()], "claim_names": claim_names, "required_claims": required_claims, "response_media_type": RESPONSE_MEDIA_TYPE, @@ -805,23 +807,14 @@ fn with_private_metadata_headers(mut response: Response) -> Response { response } -/// Attach caching directives to a resolve response. Released identity attributes -/// are PII, so the default is `private, no-store`: the bundle is never written to -/// a shared or local cache. A profile may opt into bounded *private* caching of a -/// successful release by setting `response.max_age_seconds`, which yields -/// `private, max-age=N` (still never shared-cacheable, still `Vary: Authorization` -/// so a token swap cannot reuse another principal's entry). Denials always pass -/// `max_age = None` so an error is never cached regardless of the profile knob. -fn with_release_cache_headers(mut response: Response, max_age_seconds: Option) -> Response { - let directive = match max_age_seconds { - Some(secs) => format!("private, max-age={secs}"), - None => "private, no-store".to_string(), - }; - let value = axum::http::HeaderValue::from_str(&directive) - .unwrap_or_else(|_| axum::http::HeaderValue::from_static("private, no-store")); - response - .headers_mut() - .insert(axum::http::header::CACHE_CONTROL, value); +/// Released identity attributes and every denial are non-cacheable. Resolve is +/// a POST endpoint, so a response freshness directive without a matching +/// reusable retrieval URI would create a misleading configuration contract. +fn with_release_cache_headers(mut response: Response) -> Response { + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("private, no-store"), + ); response.headers_mut().insert( axum::http::header::VARY, axum::http::HeaderValue::from_static("Authorization"), @@ -839,7 +832,7 @@ fn with_audit_context(mut response: Response, route: &RouteState, audit: Resolve table_id: Some(route.entity.table_id.clone()), ar_profile_id: Some(route.profile.id.clone()), ar_profile_version: Some(route.profile.version.clone()), - ar_subject_id_type: route.profile.subject.id_type.clone(), + ar_subject_id_type: Some(route.profile.subject.id_type.clone()), ar_subject_id_raw: audit.subject_id_raw.map(crate::audit::Sensitive::from), ar_requested_claims: audit.requested_claims, ar_released_claims: audit.released_claims, diff --git a/crates/registry-relay/src/api/openapi.rs b/crates/registry-relay/src/api/openapi.rs index 04332cf0b..8b77d1795 100644 --- a/crates/registry-relay/src/api/openapi.rs +++ b/crates/registry-relay/src/api/openapi.rs @@ -1797,7 +1797,7 @@ fn tag_definitions(catalog: &CatalogDocument, config: &Config) -> Value { tags.push(json!({ "name": TAG_ATTRIBUTE_RELEASE, "description": "Projection-limited, exactly-one-subject identity attribute \ - release; a profile is purpose-bound when it declares a purpose. \ + release; every profile is purpose-bound. \ Returns only the approved claim bundle for a named release \ profile; never a raw registry row.", })); @@ -4161,7 +4161,8 @@ fn insert_attribute_release_paths(paths: &mut Map) { these cases." ), "404": problem_response( - "Profile not found. Does not confirm whether the profile ever existed." + "Profile not found or not visible to the authenticated principal. \ + Does not confirm whether the profile ever existed." ), "503": problem_response("Source unavailable."), "default": problem_response("Problem Details error response."), @@ -5237,31 +5238,29 @@ fn attribute_release_profile_schema() -> Value { "description": "A governed identity attribute-release profile. Identifies the release \ scope, accepted subject id types, and the claim names that will be \ returned on a successful resolve.", - "required": ["profile_id", "version", "release_scope", "claim_names", "required_claims", - "accepted_subject_id_types", "response_media_type"], + "required": ["id", "version", "purpose", "release_scope", "claim_names", + "required_claims", "accepted_subject_id_types", "response_media_type"], "properties": { - "profile_id": { + "id": { "type": "string", - "description": "Profile identifier, lower-snake. Globally unique with `version`." + "description": "Profile identifier, lower-snake or lower-kebab. Globally unique \ + with `version`." }, "version": { "type": "string", - "description": "Profile version. Globally unique with `profile_id`." + "description": "Profile version. Globally unique with `id`." }, "title": { - "type": "string", - "description": "Human-readable profile title.", - "nullable": true + "type": ["string", "null"], + "description": "Human-readable profile title." }, "description": { - "type": "string", - "description": "Human-readable profile description.", - "nullable": true + "type": ["string", "null"], + "description": "Human-readable profile description." }, "purpose": { "type": "string", - "description": "Data-purpose IRI this profile is bound to.", - "nullable": true + "description": "Data-purpose token or IRI this profile is bound to." }, "accepted_subject_id_types": { "type": "array", @@ -5317,12 +5316,11 @@ fn attribute_release_resolve_request_schema() -> Value { "additionalProperties": false }, "claims": { - "type": "array", + "type": ["array", "null"], "description": "Optional subset of claim names to return. Absent means the \ profile default set; an empty array is rejected (400); \ any unknown claim name is denied.", - "items": { "type": "string" }, - "nullable": true + "items": { "type": "string" } } }, "additionalProperties": false, @@ -5347,11 +5345,6 @@ fn attribute_release_resolve_response_schema() -> Value { "type": "string", "description": "Version of the release profile used to resolve this response." }, - "purpose": { - "type": "string", - "description": "Data-purpose IRI bound to this release profile.", - "nullable": true - }, "claims": { "type": "object", "description": "Released claim bundle. Keys are claim names; values are the \ @@ -5382,8 +5375,8 @@ fn attribute_release_resolve_response_schema() -> Value { }, "cardinality": { "type": "string", - "description": "Subject cardinality expectation from the profile.", - "enum": ["one", "many"] + "description": "Stable attribute release always requires exactly one subject.", + "enum": ["one"] }, "checked_at": { "type": "string", @@ -5409,7 +5402,7 @@ mod tests { #[cfg(feature = "attribute-release")] use crate::config::{ AttributeReleaseProfile, ClaimSensitivity, ReleaseClaimConfig, ReleaseResponseConfig, - ReleaseSubjectConfig, SubjectCardinality, + ReleaseSubjectConfig, }; use crate::metadata::catalog::{CatalogLinks, DatasetLinks, EntityLinks}; @@ -5463,13 +5456,11 @@ mod tests { description: Some( "Minimal identity claims for an authenticator plugin.".to_string(), ), - purpose: None, + purpose: "identity_verification".to_string(), release_scope: "social_registry:identity_release".to_string(), subject: ReleaseSubjectConfig { - input: "individual_id".to_string(), source_field: "id".to_string(), - id_type: Some("national_id".to_string()), - cardinality: SubjectCardinality::One, + id_type: "national_id".to_string(), }, release_conditions: None, claims: vec![ReleaseClaimConfig { @@ -5480,11 +5471,9 @@ mod tests { sensitivity: Some(ClaimSensitivity::DirectIdentifier), format: None, locale: None, - shareable: false, }], response: ReleaseResponseConfig { include_source_metadata: false, - max_age_seconds: Some(300), }, }); } @@ -6465,8 +6454,9 @@ mod tests { // Required fields on AttributeReleaseProfile schema let profile_required = &schemas["AttributeReleaseProfile"]["required"]; for field in [ - "profile_id", + "id", "version", + "purpose", "release_scope", "claim_names", "required_claims", diff --git a/crates/registry-relay/src/attribute_release/mod.rs b/crates/registry-relay/src/attribute_release/mod.rs index 3cfc34a3d..a162e6897 100644 --- a/crates/registry-relay/src/attribute_release/mod.rs +++ b/crates/registry-relay/src/attribute_release/mod.rs @@ -4,9 +4,9 @@ //! Registry Relay evaluates two kinds of operator-authored CEL expressions for //! identity attribute release: //! -//! * **release predicates** (e.g. `deceased == false`) that gate whether a +//! * **release predicates** (e.g. `source.deceased == false`) that gate whether a //! subject's claims may be released at all, and -//! * **computed claim scalars** (e.g. `given_name + ' ' + surname`) that derive +//! * **computed claim scalars** (e.g. `source.given_name + ' ' + source.surname`) that derive //! a single claim value from a subject's source fields. //! //! Rather than introduce a second expression language, this module reuses the @@ -64,7 +64,7 @@ pub use disabled::{ mod enabled { use super::AttributeReleaseError; - use std::collections::HashMap; + use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, RwLock}; use serde_json::{json, Value}; @@ -241,10 +241,80 @@ mod enabled { /// expression that does not compile is rejected before the runtime serves /// any request. pub fn validate_release_expression(cel: &str) -> Result<(), AttributeReleaseError> { + validate_expression_authority(cel)?; let runtime = MappingRuntime::new(RuntimeOptions::default()); compile(&runtime, cel).map(|_| ()) } + /// Restrict release expressions to the projected `source` object. The + /// Crosswalk runtime also offers a `context` object, but Relay does not + /// define an attribute-release authority contract for it. Rejecting any + /// other member root at config load prevents raw Relay configuration from + /// acquiring capabilities that Registryctl-authored profiles do not have. + fn validate_expression_authority(cel: &str) -> Result<(), AttributeReleaseError> { + let roots = cel_member_roots(cel) + .map_err(|()| AttributeReleaseError::Compile("kind=authority".to_string()))?; + if roots != BTreeSet::from(["source".to_string()]) { + return Err(AttributeReleaseError::Compile("kind=authority".to_string())); + } + Ok(()) + } + + /// Collect dotted or bracketed CEL member roots while ignoring quoted string + /// contents. The reserved `context` root is collected even when bare. + /// This mirrors Registryctl's authoring validator so direct Relay config and + /// generated config enforce the same source-only authority boundary. + fn cel_member_roots(expression: &str) -> Result, ()> { + let bytes = expression.as_bytes(); + let mut roots = BTreeSet::new(); + let mut index = 0; + while index < bytes.len() { + if matches!(bytes[index], b'\'' | b'"') { + let quote = bytes[index]; + index += 1; + let mut escaped = false; + let mut closed = false; + while index < bytes.len() { + let byte = bytes[index]; + index += 1; + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == quote { + closed = true; + break; + } + } + if !closed { + return Err(()); + } + continue; + } + if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' { + let start = index; + index += 1; + while index < bytes.len() + && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') + { + index += 1; + } + let mut member_index = index; + while bytes.get(member_index).is_some_and(u8::is_ascii_whitespace) { + member_index += 1; + } + if &expression[start..index] == "context" + || matches!(bytes.get(member_index), Some(b'.' | b'[')) + { + roots.insert(expression[start..index].to_string()); + } + continue; + } + index += 1; + } + Ok(roots) + } + /// Evaluate a release **predicate** over a subject record. Fails closed: /// compile error, evaluation error, missing field, or a non-boolean result /// all return `Err` rather than a permissive `true`. @@ -442,6 +512,33 @@ mod enabled { assert!(!err.to_string().contains("given_name")); } + #[test] + fn expression_authority_rejects_context_members() { + let err = validate_release_expression("context.subject == source.subject") + .expect_err("context authority must be rejected"); + assert!(matches!(err, AttributeReleaseError::Compile(_))); + assert!(!err.to_string().contains("subject")); + + let err = validate_release_expression("source.subject == context [\"subject\"]") + .expect_err("bracketed context authority must be rejected"); + assert!(matches!(err, AttributeReleaseError::Compile(_))); + } + + #[test] + fn expression_authority_ignores_member_decoys_in_strings() { + validate_release_expression( + "source.given_name == 'context.secret' || source.given_name == \"request.value\"", + ) + .expect("quoted member-like text is not authority"); + } + + #[test] + fn expression_authority_rejects_unterminated_strings() { + let err = validate_release_expression("source.given_name == 'unterminated") + .expect_err("unterminated string must be rejected"); + assert!(matches!(err, AttributeReleaseError::Compile(_))); + } + #[test] fn compiled_mapping_is_cached_and_reused() { // The fix for the per-resolve recompile: identical expression text diff --git a/crates/registry-relay/src/config/mod.rs b/crates/registry-relay/src/config/mod.rs index ed94f11d9..ca9d6685e 100644 --- a/crates/registry-relay/src/config/mod.rs +++ b/crates/registry-relay/src/config/mod.rs @@ -1624,10 +1624,9 @@ pub struct EntityConfig { /// A governed identity attribute-release profile. A profile is a /// projection-limited, exactly-one-subject lookup that maps a configured set of /// source fields (or CEL-computed expressions) into a minimised -/// OIDC/UserInfo-style claim bundle. It is *optionally* purpose-bound: a profile -/// that declares a `purpose` requires a matching `data-purpose` at resolve time; -/// one that omits it does not. Identified globally by the `(id, version)` pair; -/// both are required path segments at resolve time. +/// OIDC/UserInfo-style claim bundle. Every profile is purpose-bound and requires +/// a matching `data-purpose` at resolve time. Identified globally by the +/// `(id, version)` pair; both are required path segments at resolve time. #[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct AttributeReleaseProfile { @@ -1640,17 +1639,15 @@ pub struct AttributeReleaseProfile { pub title: Option, #[serde(default)] pub description: Option, - /// Data-purpose this profile is bound to. Required (and must be a member - /// of the entity's `governed_policy.permitted_purposes`) when the backing - /// entity declares any permitted purposes. - #[serde(default)] - pub purpose: Option, - /// Dataset-bound scope a caller must hold to invoke this release. Must - /// differ from the entity's `read_scope`. + /// Data-purpose this profile is bound to. When the backing entity declares + /// `governed_policy.permitted_purposes`, this must be a member. + pub purpose: String, + /// Dataset-bound scope a caller must hold to invoke this release. The + /// stable profile is exactly `:identity_release`. pub release_scope: String, /// How the subject is identified and looked up. pub subject: ReleaseSubjectConfig, - /// Optional CEL release-condition gate evaluated before projection. + /// Optional CEL release-condition gate evaluated over the redacted row. #[serde(default)] pub release_conditions: Option, /// Claims released on success. Non-empty; at least one `required`. @@ -1664,39 +1661,18 @@ pub struct AttributeReleaseProfile { #[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReleaseSubjectConfig { - /// Request input that carries the subject identifier. - pub input: String, /// Source field used to match the subject. Must be an exposed entity field. pub source_field: String, - /// Optional accepted identifier type label. - #[serde(default)] - pub id_type: Option, - /// Expected subject cardinality. Defaults to exactly one. - #[serde(default = "default_subject_cardinality")] - pub cardinality: SubjectCardinality, -} - -/// Expected number of subjects a release lookup may match. -#[derive(Debug, Clone, Copy, Deserialize, JsonSchema, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum SubjectCardinality { - One, - Many, -} - -fn default_subject_cardinality() -> SubjectCardinality { - SubjectCardinality::One + /// Accepted identifier type label. + pub id_type: String, } -/// CEL release-condition gate. When present, the predicate must hold before -/// any claim is projected; failure fails closed (subject denied). +/// CEL release-condition gate. When present, the predicate must hold over the +/// governed-redacted row before any claim is projected; failure fails closed. #[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] #[serde(deny_unknown_fields)] pub struct ReleaseConditionsConfig { pub expression: ReleaseExpressionConfig, - /// Optional internal audit code for a release-condition denial. - #[serde(default)] - pub denied_code: Option, } /// A single CEL expression evaluated over the subject's source projection. @@ -1732,13 +1708,6 @@ pub struct ReleaseClaimConfig { /// Optional locale hint. #[serde(default)] pub locale: Option, - /// Whether the claim may be shared downstream. Defaults to true. - #[serde(default = "default_claim_shareable")] - pub shareable: bool, -} - -fn default_claim_shareable() -> bool { - true } /// Closed privacy-sensitivity classification for a released claim. This is a @@ -1760,9 +1729,6 @@ pub struct ReleaseResponseConfig { /// Whether to include profile-sourced metadata in the response body. #[serde(default)] pub include_source_metadata: bool, - /// Optional cache lifetime hint for the released bundle, in seconds. - #[serde(default)] - pub max_age_seconds: Option, } pub const CRS84: &str = "http://www.opengis.net/def/crs/OGC/1.3/CRS84"; @@ -2383,21 +2349,10 @@ state_plane: assert_eq!(Suppression::default(), Suppression::Omit); } - #[test] - fn default_subject_cardinality_is_one() { - assert_eq!(default_subject_cardinality(), SubjectCardinality::One); - } - - #[test] - fn default_claim_shareable_is_true() { - assert!(default_claim_shareable()); - } - #[test] fn release_response_config_default_is_minimal() { let response = ReleaseResponseConfig::default(); assert!(!response.include_source_metadata); - assert_eq!(response.max_age_seconds, None); } #[test] diff --git a/crates/registry-relay/src/config/validate.rs b/crates/registry-relay/src/config/validate.rs index 233272570..01da539f2 100644 --- a/crates/registry-relay/src/config/validate.rs +++ b/crates/registry-relay/src/config/validate.rs @@ -3698,6 +3698,22 @@ fn release_claim_has_exactly_one_source(claim: &ReleaseClaimConfig) -> bool { claim.source_field.is_some() ^ claim.expression.is_some() } +fn is_bounded_release_token(value: &str, max_bytes: usize) -> bool { + !value.is_empty() + && value.len() <= max_bytes + && !value.contains(',') + && !value + .chars() + .any(|character| character.is_control() || character.is_whitespace()) +} + +fn is_bounded_release_text(value: &str) -> bool { + !value.is_empty() + && value.len() <= 2048 + && value.trim() == value + && !value.chars().any(char::is_control) +} + /// Validate the attribute-release profiles attached to an entity (plan §5.1). /// /// `exposed_fields` is the entity's resolved projection (claim/subject @@ -3705,8 +3721,8 @@ fn release_claim_has_exactly_one_source(claim: &ReleaseClaimConfig) -> bool { /// `validate_entity_filters` membership discipline. CEL expressions are /// compile-checked through the Relay-owned adapter when the /// `attribute-release` feature is enabled. When the feature is disabled, any -/// configured profile is rejected at load so the default 1.0 build does not -/// silently accept a beta config surface whose routes are not mounted. +/// configured profile is rejected at load so a deliberately minimal build does +/// not silently accept a config surface whose routes are not mounted. fn validate_entity_release_profiles( dataset: &DatasetConfig, entity: &EntityConfig, @@ -3750,11 +3766,47 @@ fn validate_entity_release_profile( if profile.id.trim().is_empty() { return release_error("attribute_release_profiles id must not be empty"); } - if !is_valid_profile_id(&profile.id) { - return release_error("attribute_release_profiles id does not match ^[a-z][a-z0-9_-]*$"); + if profile.id.len() > 96 || !is_valid_profile_id(&profile.id) { + return release_error("attribute_release_profiles id must match ^[a-z][a-z0-9_-]{0,95}$"); + } + if !is_bounded_release_token(&profile.version, 64) { + return release_error( + "attribute_release_profiles version must be one bounded token of at most 64 bytes", + ); + } + if profile + .title + .as_deref() + .is_some_and(|value| !is_bounded_release_text(value)) + || profile + .description + .as_deref() + .is_some_and(|value| !is_bounded_release_text(value)) + { + return release_error( + "attribute_release_profiles title and description must be bounded, trimmed text", + ); + } + if !is_bounded_release_token(&profile.purpose, 256) { + return release_error( + "attribute_release_profiles purpose must be one bounded token of at most 256 bytes", + ); + } + let expected_release_scope = format!("{}:identity_release", dataset.id); + if profile.release_scope != expected_release_scope { + return release_error( + "attribute_release_profiles release_scope must be the dataset-bound identity_release scope", + ); + } + if !entity.api.required_filters.is_empty() { + return release_error( + "attribute_release_profiles cannot use an entity with required_filters because the caller-supplied subject cannot satisfy a principal-bound filter", + ); } - if profile.version.trim().is_empty() { - return release_error("attribute_release_profiles version must not be empty"); + if entity.api.max_limit < 2 { + return release_error( + "attribute_release_profiles require entity api.max_limit of at least 2 to detect ambiguous subjects", + ); } // Subject source field must be exposed by the entity projection. @@ -3763,21 +3815,23 @@ fn validate_entity_release_profile( "attribute_release_profiles subject.source_field references a non-exposed field", ); } - if profile.subject.input.trim().is_empty() { - return release_error("attribute_release_profiles subject.input must not be empty"); + if !is_bounded_release_token(&profile.subject.id_type, 64) { + return release_error( + "attribute_release_profiles subject.id_type must be one bounded token of at most 64 bytes", + ); } // Claims: non-empty, ≥1 required, unique names, each a valid id, and each // declaring exactly one of source_field / expression.cel. - if profile.claims.is_empty() { - return release_error("attribute_release_profiles must declare at least one claim"); + if profile.claims.is_empty() || profile.claims.len() > 32 { + return release_error("attribute_release_profiles must declare between one and 32 claims"); } let mut claim_names: HashSet<&str> = HashSet::new(); let mut has_required = false; for claim in &profile.claims { - if !is_valid_claim_name(&claim.name) { + if claim.name.len() > 64 || !is_valid_claim_name(&claim.name) { return release_error( - "attribute_release_profiles claim name does not match \ + "attribute_release_profiles claim name must be at most 64 bytes and match \ ^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$", ); } @@ -3813,22 +3867,17 @@ fn validate_entity_release_profile( } // Purpose coupling: when the backing entity governs purposes, the profile - // purpose must be set and a member of the permitted set. + // purpose must be a member of the permitted set. if let Some(policy) = entity.api.governed_policy.as_ref() { - if !policy.permitted_purposes.is_empty() { - match profile.purpose.as_ref() { - Some(purpose) if policy.permitted_purposes.iter().any(|p| p == purpose) => {} - Some(_) => { - return release_error( - "attribute_release_profiles purpose must be one of the entity governed_policy permitted_purposes", - ); - } - None => { - return release_error( - "attribute_release_profiles purpose is required when the entity governs permitted_purposes", - ); - } - } + if !policy.permitted_purposes.is_empty() + && !policy + .permitted_purposes + .iter() + .any(|purpose| purpose == &profile.purpose) + { + return release_error( + "attribute_release_profiles purpose must be one of the entity governed_policy permitted_purposes", + ); } } @@ -3891,6 +3940,16 @@ fn compile_release_expression( profile: &AttributeReleaseProfile, cel: &str, ) -> Result<(), ConfigError> { + if cel.is_empty() || cel.len() > 4096 { + tracing::error!( + code = "config.validation_error", + dataset_id = %dataset.id, + entity = %entity.name, + profile_id = %profile.id, + "attribute_release_profiles CEL expression must contain between one and 4096 bytes" + ); + return Err(ConfigError::ValidationError); + } crate::attribute_release::validate_release_expression(cel).map_err(|err| { tracing::error!( code = "config.validation_error", @@ -5512,7 +5571,6 @@ deployment: sensitivity: None, format: None, locale: None, - shareable: true, } } diff --git a/crates/registry-relay/src/lib.rs b/crates/registry-relay/src/lib.rs index 53b036725..9f7ed6a2f 100644 --- a/crates/registry-relay/src/lib.rs +++ b/crates/registry-relay/src/lib.rs @@ -48,3 +48,26 @@ pub mod spdci; )] mod state_plane; pub mod table_provider; + +/// Exact Cargo feature set compiled into this Relay binary. Operators can read +/// the same list through the protected admin capabilities endpoint, and release +/// image metadata records the canonical production value independently. +#[must_use] +pub fn compiled_cargo_features() -> &'static [&'static str] { + &[ + #[cfg(feature = "attribute-release")] + "attribute-release", + #[cfg(feature = "crosswalk-runtime")] + "crosswalk-runtime", + #[cfg(feature = "standards-cel-mapping")] + "standards-cel-mapping", + #[cfg(feature = "spdci-api-standards")] + "spdci-api-standards", + #[cfg(feature = "ogcapi-features")] + "ogcapi-features", + #[cfg(feature = "ogcapi-edr")] + "ogcapi-edr", + #[cfg(feature = "ogcapi-records")] + "ogcapi-records", + ] +} diff --git a/crates/registry-relay/tests/attribute_release_api.rs b/crates/registry-relay/tests/attribute_release_api.rs index 4b54804b2..ab082b4d6 100644 --- a/crates/registry-relay/tests/attribute_release_api.rs +++ b/crates/registry-relay/tests/attribute_release_api.rs @@ -72,23 +72,7 @@ impl From for TestServerBuildError { /// drives the release-condition predicate; `given_name`/`surname` back direct /// and computed claims. The `optional_note` claim is optional and absent on the /// stored row so it is omitted from a successful release. -fn release_config( - entity_api_extra: &str, - include_source_metadata: bool, - max_age_seconds: Option, - purpose: Option<&str>, -) -> String { - let max_age_line = match max_age_seconds { - Some(secs) => format!("\n max_age_seconds: {secs}"), - None => String::new(), - }; - // A purpose-bound profile declares `purpose`; an unbound one omits it. The - // default fixture is unbound so the bulk of the tests need no data-purpose - // header; purpose-gate and governed-policy tests pass an explicit purpose. - let purpose_line = match purpose { - Some(value) => format!(" purpose: {value}\n"), - None => String::new(), - }; +fn release_config(entity_api_extra: &str, include_source_metadata: bool, purpose: &str) -> String { format!( r#" server: @@ -175,9 +159,9 @@ datasets: version: v1 title: Civil identity bundle description: Minimised identity claims for eSignet. -{purpose_line} release_scope: {RELEASE_SCOPE} + purpose: {purpose} + release_scope: {RELEASE_SCOPE} subject: - input: subject_token source_field: national_id id_type: NATIONAL_ID release_conditions: @@ -195,7 +179,7 @@ datasets: source_field: surname required: false response: - include_source_metadata: {include_source_metadata}{max_age_line} + include_source_metadata: {include_source_metadata} "# ) } @@ -224,23 +208,18 @@ async fn try_server_with_scopes_and_extra( scopes: &[&str], entity_api_extra: &str, ) -> Result { - // Default fixture is purpose-unbound, so resolve requests need no - // data-purpose header; purpose-gate/governed tests build with an explicit - // purpose via `try_server_full`. - try_server_full(scopes, entity_api_extra, true, None, None).await + try_server_full(scopes, entity_api_extra, true, None).await } /// Like [`try_server_with_scopes_and_extra`] but with explicit control over the -/// profile's `response.include_source_metadata` flag (so both branches of the -/// source-block gate can be exercised), its `response.max_age_seconds` cache -/// opt-in (so the default `no-store` and the `private, max-age=N` paths can be -/// asserted), and its `purpose` binding (so the data-purpose gate can be -/// exercised). A `Some` purpose makes the profile purpose-bound. +/// profile's `response.include_source_metadata` flag and its `purpose` binding. +/// Every profile is purpose-bound. `None` selects the default `identity` +/// purpose and installs that header on the test server; `Some` configures the +/// supplied purpose without a default header so purpose denials can be tested. async fn try_server_full( scopes: &[&str], entity_api_extra: &str, include_source_metadata: bool, - max_age_seconds: Option, purpose: Option<&str>, ) -> Result { let tmp = TempDir::new().expect("tempdir"); @@ -250,8 +229,7 @@ async fn try_server_full( release_config( entity_api_extra, include_source_metadata, - max_age_seconds, - purpose, + purpose.unwrap_or("identity"), ), ) .expect("write config"); @@ -303,7 +281,11 @@ async fn try_server_full( .layer(Extension(registry)) .layer(Extension(evaluator)) .layer(Extension(config)); - Ok(TestServer::new(app)) + let mut server = TestServer::new(app); + if purpose.is_none() { + server.add_header("data-purpose", "identity"); + } + Ok(server) } async fn server() -> TestServer { @@ -352,7 +334,7 @@ async fn resolve_omits_source_block_when_metadata_disabled() { // an eSignet authenticator profile), the claim bundle is still released but // the source block — which would disclose the backing dataset/entity names — // is suppressed entirely. - let server = try_server_full(&[RELEASE_SCOPE], "", false, None, None) + let server = try_server_full(&[RELEASE_SCOPE], "", false, None) .await .expect("test server builds"); let response = server.post(RESOLVE_PATH).json(&subject_body("NID-1")).await; @@ -564,7 +546,6 @@ async fn resolve_required_claim_missing_denies() { trusted_context: {} "#, true, - None, Some("identity"), ) .await @@ -592,7 +573,6 @@ async fn resolve_optional_claim_omitted_when_source_redacted() { trusted_context: {} "#, true, - None, Some("identity"), ) .await @@ -628,7 +608,6 @@ async fn resolve_computed_claim_cannot_read_redacted_field() { trusted_context: {} "#, true, - None, Some("identity"), ) .await @@ -654,6 +633,34 @@ async fn resolve_computed_claim_cannot_read_redacted_field() { ); } +#[tokio::test] +async fn resolve_release_condition_cannot_read_redacted_field() { + // The release predicate reads `source.deceased`. If the PDP redacts that + // field, the predicate must evaluate over the redacted row and fail closed + // instead of revealing a boolean about the removed value. + let server = try_server_full( + &[RELEASE_SCOPE], + r#" governed_policy: + permitted_purposes: + - identity + redaction_fields: [deceased] + trusted_context: {} +"#, + true, + Some("identity"), + ) + .await + .expect("test server builds"); + + let response = server + .post(RESOLVE_PATH) + .add_header("data-purpose", "identity") + .json(&subject_body("NID-1")) + .await; + response.assert_status(StatusCode::FORBIDDEN); + assert_eq!(response.json::()["code"], "release.subject_denied"); +} + // --------------------------------------------------------------------------- // Scope / purpose deny-before-read // --------------------------------------------------------------------------- @@ -662,7 +669,7 @@ async fn resolve_computed_claim_cannot_read_redacted_field() { async fn resolve_purpose_bound_profile_accepts_matching_purpose() { // A purpose-bound profile (purpose set, entity NOT otherwise governing // purposes) resolves when the data-purpose header equals the profile purpose. - let server = try_server_full(&[RELEASE_SCOPE], "", true, None, Some("identity")) + let server = try_server_full(&[RELEASE_SCOPE], "", true, Some("identity")) .await .expect("test server builds"); let response = server @@ -679,7 +686,7 @@ async fn resolve_purpose_bound_profile_missing_header_is_purpose_required() { // Without a backing governed_policy the entity would not require purpose, but // the profile purpose binding does: a missing data-purpose header is rejected // before the read with 400 auth.purpose_required. - let server = try_server_full(&[RELEASE_SCOPE], "", true, None, Some("identity")) + let server = try_server_full(&[RELEASE_SCOPE], "", true, Some("identity")) .await .expect("test server builds"); let response = server.post(RESOLVE_PATH).json(&subject_body("NID-1")).await; @@ -691,7 +698,7 @@ async fn resolve_purpose_bound_profile_missing_header_is_purpose_required() { async fn resolve_purpose_bound_profile_wrong_purpose_is_denied() { // A data-purpose that does not equal the profile purpose is denied before the // read with 403 auth.purpose_denied. - let server = try_server_full(&[RELEASE_SCOPE], "", true, None, Some("identity")) + let server = try_server_full(&[RELEASE_SCOPE], "", true, Some("identity")) .await .expect("test server builds"); let response = server @@ -704,21 +711,30 @@ async fn resolve_purpose_bound_profile_wrong_purpose_is_denied() { } #[tokio::test] -async fn resolve_without_release_scope_is_denied_before_read() { - // A caller holding only the row-read scope cannot invoke a release. +async fn resolve_without_release_scope_is_hidden_like_unknown_profile() { + // Discovery hides profiles without the release scope. Resolve must return + // the same response for that known profile and an unknown profile so the + // path cannot be used as a profile-id oracle. let server = try_server_with_scopes_and_extra(&[READ_SCOPE], "") .await .expect("test server builds"); - let response = server.post(RESOLVE_PATH).json(&subject_body("NID-1")).await; - response.assert_status(StatusCode::FORBIDDEN); - assert_eq!(response.json::()["code"], "auth.scope_denied"); + let known = server.post(RESOLVE_PATH).json(&subject_body("NID-1")).await; + let unknown = server + .post("/v1/attribute-releases/unknown/versions/v1/resolve") + .json(&subject_body("NID-1")) + .await; + known.assert_status(StatusCode::NOT_FOUND); + unknown.assert_status(StatusCode::NOT_FOUND); + assert_eq!(known.json::(), unknown.json::()); } #[tokio::test] async fn resolve_missing_purpose_denies_before_read() { - let server = try_server_with_scopes_and_extra( + let server = try_server_full( &[RELEASE_SCOPE], " require_purpose_header: true\n", + true, + Some("identity"), ) .await .expect("test server builds"); @@ -737,7 +753,7 @@ fn config_accepts_hyphenated_profile_id_and_dotted_claim_name() { "REGISTRY_RELAY_TEST_AUDIT_HASH_SECRET", "relay-release-audit-secret-32-bytes", ); - let yaml = release_config("", false, None, Some("identity")) + let yaml = release_config("", false, "identity") .replace("id: civil_identity", "id: esignet-civil-userinfo") .replace("name: optional_note", "name: address.region"); let tmp = TempDir::new().expect("tempdir"); @@ -857,8 +873,8 @@ async fn discovery_sets_private_metadata_headers() { #[tokio::test] async fn resolve_success_defaults_to_no_store() { - // A released identity bundle is PII; with no `response.max_age_seconds` - // configured the response must forbid any caching. + // A released identity bundle is PII and the POST response has no reusable + // retrieval URI, so every success must forbid caching. let server = server().await; let response = server.post(RESOLVE_PATH).json(&subject_body("NID-1")).await; response.assert_status(StatusCode::OK); @@ -873,33 +889,8 @@ async fn resolve_success_defaults_to_no_store() { } #[tokio::test] -async fn resolve_success_honours_configured_max_age() { - // A profile may opt into bounded *private* caching of a successful release; - // `response.max_age_seconds: 300` yields `private, max-age=300` (never a - // shared cache, still keyed by Authorization via Vary). - let server = try_server_full(&[RELEASE_SCOPE], "", true, Some(300), None) - .await - .expect("test server builds"); - let response = server.post(RESOLVE_PATH).json(&subject_body("NID-1")).await; - response.assert_status(StatusCode::OK); - assert_eq!( - response.header("cache-control").to_str().expect("ascii"), - "private, max-age=300" - ); - assert_eq!( - response.header("vary").to_str().expect("ascii"), - "Authorization" - ); -} - -#[tokio::test] -async fn resolve_denial_is_never_cached_even_with_max_age() { - // Denials must never be cached regardless of the profile's caching opt-in: - // a missing subject collapses to `release.subject_denied` (403) and the - // response must still be `private, no-store`, not `max-age=300`. - let server = try_server_full(&[RELEASE_SCOPE], "", true, Some(300), None) - .await - .expect("test server builds"); +async fn resolve_denial_is_never_cached() { + let server = server().await; let response = server .post(RESOLVE_PATH) .json(&subject_body("NID-MISSING")) diff --git a/crates/registry-relay/tests/attribute_release_feature_flag.rs b/crates/registry-relay/tests/attribute_release_feature_flag.rs index f89a0ba1c..6c1df436c 100644 --- a/crates/registry-relay/tests/attribute_release_feature_flag.rs +++ b/crates/registry-relay/tests/attribute_release_feature_flag.rs @@ -2,7 +2,7 @@ #![cfg(not(feature = "attribute-release"))] //! Guardrail for binaries built without the CEL-backed attribute-release adapter -//! (`cargo build`, the 1.0 default feature shape). The +//! (`cargo build --no-default-features`, the deliberately minimal shape). The //! `attribute_release_profiles` field is parsed in every build, but profiles are //! rejected unless the `attribute-release` feature is explicitly enabled so the //! default binary cannot accept config for routes it does not mount. @@ -108,9 +108,8 @@ datasets: title: Civil identity bundle description: Minimised identity claims. purpose: identity - release_scope: civil_registry:release + release_scope: civil_registry:identity_release subject: - input: subject_token source_field: national_id id_type: NATIONAL_ID claims: diff --git a/crates/registry-relay/tests/config_entities.rs b/crates/registry-relay/tests/config_entities.rs index 4dc2e3221..f157a8365 100644 --- a/crates/registry-relay/tests/config_entities.rs +++ b/crates/registry-relay/tests/config_entities.rs @@ -500,10 +500,11 @@ fn dataset_with_release_profiles(profiles_yaml: &str) -> String { fn valid_release_profile() -> String { r#" - id: basic_identity version: "1" + purpose: identity_verification release_scope: social_registry:identity_release subject: - input: individual_id source_field: id + id_type: national_id claims: - name: subject_identifier source_field: id @@ -627,6 +628,17 @@ fn release_profile_release_scope_must_be_dataset_bound() { assert_eq!(err, "config.validation_error"); } +#[cfg(feature = "attribute-release")] +#[test] +fn release_profile_release_scope_must_use_identity_release_suffix() { + let profile = valid_release_profile().replace( + "release_scope: social_registry:identity_release", + "release_scope: social_registry:release", + ); + let err = load_release_dataset(&profile).expect_err("release scope must use identity_release"); + assert_eq!(err, "config.validation_error"); +} + #[cfg(feature = "attribute-release")] #[test] fn release_profile_release_scope_must_differ_from_read_scope() { @@ -639,6 +651,49 @@ fn release_profile_release_scope_must_differ_from_read_scope() { assert_eq!(err, "config.validation_error"); } +#[cfg(feature = "attribute-release")] +#[test] +fn release_profile_requires_bounded_purpose_and_subject_id_type() { + let empty_purpose = + valid_release_profile().replace("purpose: identity_verification", "purpose: \"\""); + assert_eq!( + load_release_dataset(&empty_purpose).expect_err("empty purpose rejected"), + "config.validation_error" + ); + + let empty_id_type = valid_release_profile().replace("id_type: national_id", "id_type: \"\""); + assert_eq!( + load_release_dataset(&empty_id_type).expect_err("empty id_type rejected"), + "config.validation_error" + ); +} + +#[cfg(feature = "attribute-release")] +#[test] +fn release_profile_rejects_principal_bound_required_filter_entities() { + let tmp = TempDir::new().expect("tempdir"); + let dataset = dataset_with_release_profiles(&valid_release_profile()).replace( + " allowed_expansions: [household]\n", + " required_filters: [id]\n required_filter_bindings:\n - field: id\n source: principal_id\n allowed_expansions: [household]\n", + ); + let config_path = write_config(&tmp, &base_config(&dataset)); + let err = registry_relay::config::load(&config_path) + .expect_err("caller-controlled release subject cannot satisfy required_filters"); + assert_eq!(err.code(), "config.validation_error"); +} + +#[cfg(feature = "attribute-release")] +#[test] +fn release_profile_requires_max_limit_to_detect_ambiguity() { + let tmp = TempDir::new().expect("tempdir"); + let dataset = dataset_with_release_profiles(&valid_release_profile()) + .replace(" max_limit: 1000\n", " max_limit: 1\n"); + let config_path = write_config(&tmp, &base_config(&dataset)); + let err = registry_relay::config::load(&config_path) + .expect_err("exact-one lookup requires limit two for ambiguity detection"); + assert_eq!(err.code(), "config.validation_error"); +} + #[cfg(feature = "attribute-release")] #[test] fn release_profile_id_version_pair_must_be_globally_unique() { @@ -688,3 +743,14 @@ fn release_profile_rejects_invalid_cel_expression() { let err = load_release_dataset(&profile).expect_err("invalid CEL rejected"); assert_eq!(err, "config.validation_error"); } + +#[cfg(feature = "attribute-release")] +#[test] +fn release_profile_cel_may_reference_only_projected_source() { + let profile = valid_release_profile().replace( + " - name: municipality\n source_field: municipality_code\n", + " - name: municipality\n expression:\n cel: context.municipality_code\n", + ); + let err = load_release_dataset(&profile).expect_err("context CEL authority rejected"); + assert_eq!(err, "config.validation_error"); +} diff --git a/crates/registry-relay/tests/observability_metrics.rs b/crates/registry-relay/tests/observability_metrics.rs index 6ffdfe1a7..923278201 100644 --- a/crates/registry-relay/tests/observability_metrics.rs +++ b/crates/registry-relay/tests/observability_metrics.rs @@ -358,6 +358,24 @@ async fn metrics_requires_metrics_scope_on_admin_listener() { .assert_status(StatusCode::OK); } +#[tokio::test] +async fn capabilities_report_the_compiled_cargo_feature_set() { + let fixture = build_fixture(); + + let response = fixture + .admin + .get("/admin/v1/capabilities") + .add_header("x-api-key", OPS_TOKEN) + .await; + + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!( + body["cargo_features"], + serde_json::json!(registry_relay::compiled_cargo_features()) + ); +} + #[tokio::test] async fn denied_admin_and_metrics_requests_do_not_leak_privileged_surfaces() { let fixture = build_fixture(); diff --git a/crates/registry-relay/tests/protected_cache_control.rs b/crates/registry-relay/tests/protected_cache_control.rs index fae299f81..1be7b47dc 100644 --- a/crates/registry-relay/tests/protected_cache_control.rs +++ b/crates/registry-relay/tests/protected_cache_control.rs @@ -159,9 +159,9 @@ datasets: version: v1 title: Individual identity bundle description: Minimal bundle for cache-control coverage. + purpose: identity release_scope: social_registry:identity_release subject: - input: subject_token source_field: id id_type: INDIVIDUAL_ID release_conditions: @@ -364,6 +364,7 @@ async fn attribute_release_family_carries_single_cache_control_header() { let resp = server .post("/v1/attribute-releases/individual_identity/versions/v1/resolve") .add_header("Authorization", format!("Bearer {API_KEY}")) + .add_header("data-purpose", "identity") .json(&json!({ "subject": { "id_type": "INDIVIDUAL_ID", "value": "ind-1" } })) .await; resp.assert_status_ok(); diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 13dca4bf5..d54045bef 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed + +- Attribute-release project authoring now emits the stable Relay profile + contract with required purpose, exact subject type, and exact-one lookup + semantics. + +### Removed + +- Removed the unused attribute-release subject input and response cache-age + fields from project authoring. Generated profiles remain non-storable and do + not opt into source metadata disclosure. + ## [0.13.0] - 2026-07-25 ### Changed diff --git a/crates/registryctl/schemas/project-authoring/project.schema.json b/crates/registryctl/schemas/project-authoring/project.schema.json index 4f8bb8415..7289e7144 100644 --- a/crates/registryctl/schemas/project-authoring/project.schema.json +++ b/crates/registryctl/schemas/project-authoring/project.schema.json @@ -149,7 +149,7 @@ } }, "recordAttributeReleaseProfiles": { - "description": "Purpose-bound, exact-one identity releases keyed by stable profile id. Generated Relay responses always omit source metadata.", + "description": "Purpose-bound, exact-one identity releases keyed by stable profile id. Generated Relay responses omit source metadata and are never cacheable.", "type": "object", "maxProperties": 16, "propertyNames": { "type": "string", "pattern": "^[a-z][a-z0-9_-]{0,95}$" }, @@ -169,9 +169,8 @@ "subject": { "type": "object", "additionalProperties": false, - "required": ["input", "source_field", "id_type"], + "required": ["source_field", "id_type"], "properties": { - "input": { "type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$" }, "source_field": { "$ref": "#/$defs/stableId" }, "id_type": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[^,\\s\\u0000-\\u001F\\u007F]+$" } } @@ -202,12 +201,6 @@ { "required": ["expression"] } ] } - }, - "response": { - "description": "Private cache control for the minimized response. Source metadata disclosure is intentionally unavailable.", - "type": "object", - "additionalProperties": false, - "properties": { "max_age_seconds": { "type": "integer", "minimum": 1, "maximum": 3600 } } } } }, diff --git a/crates/registryctl/src/project_authoring/compiler/relay.rs b/crates/registryctl/src/project_authoring/compiler/relay.rs index 54a35383e..9470ebcc3 100644 --- a/crates/registryctl/src/project_authoring/compiler/relay.rs +++ b/crates/registryctl/src/project_authoring/compiler/relay.rs @@ -532,17 +532,11 @@ fn generated_attribute_release_profiles(api: &RecordsApiDeclaration) -> Vec, - #[serde(default)] - response: RecordAttributeReleaseResponse, } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct RecordAttributeReleaseSubject { - input: String, source_field: String, id_type: String, } @@ -463,13 +459,6 @@ enum RecordAttributeReleaseSensitivity { Pseudonymous, } -#[derive(Debug, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -struct RecordAttributeReleaseResponse { - #[serde(default)] - max_age_seconds: Option, -} - #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] struct RecordScopes { diff --git a/crates/registryctl/src/project_authoring/project.rs b/crates/registryctl/src/project_authoring/project.rs index 832a9d2a9..fc5cc2d8f 100644 --- a/crates/registryctl/src/project_authoring/project.rs +++ b/crates/registryctl/src/project_authoring/project.rs @@ -1487,8 +1487,6 @@ fn validate_record_attribute_release_profiles( ) { bail!("{collision}"); } - validate_input_name(&profile.subject.input) - .context("attribute release subject input is invalid")?; validate_token( &profile.subject.id_type, "attribute release subject id_type", @@ -1533,13 +1531,6 @@ fn validate_record_attribute_release_profiles( if !has_required_claim { bail!("attribute release profiles require at least one required claim"); } - if profile - .response - .max_age_seconds - .is_some_and(|seconds| !(1..=3600).contains(&seconds)) - { - bail!("attribute release response max_age_seconds must be between 1 and 3600"); - } } Ok(()) } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index c1c484dce..8eee1814c 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -428,15 +428,14 @@ outputs: assert_eq!(profile["id"], "solmara-nia-userinfo"); assert_eq!(profile["version"], "v1"); assert_eq!(profile["release_scope"], "population:identity_release"); - assert_eq!(profile["subject"]["input"], "individual_id"); assert_eq!(profile["subject"]["source_field"], "legacy_nid"); - assert_eq!(profile["subject"]["cardinality"], "one"); + assert!(profile["subject"].get("input").is_none()); + assert!(profile["subject"].get("cardinality").is_none()); assert_eq!( profile["release_conditions"]["expression"]["cel"], "source.identity_status == 'active' && source.alive == true" ); - assert_eq!(profile["response"]["include_source_metadata"], false); - assert_eq!(profile["response"]["max_age_seconds"], 300); + assert!(profile.get("response").is_none()); let claims = profile["claims"] .as_array() .expect("release claims are a closed list") diff --git a/crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release/README.md b/crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release/README.md index 4c66bb798..646973898 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release/README.md +++ b/crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release/README.md @@ -15,7 +15,8 @@ review shows the complete release policy in one place. Direct claims name an explicitly projected entity field; computed claims and the required release condition may reference only the projected `source` object. -The compiler fixes subject cardinality to exactly one and always generates -`include_source_metadata: false`. Authors may set only a private cache lifetime -from 1 to 3,600 seconds. The authored profile and generated Relay configuration -are both covered by the signed project semantic and closure digests. +The compiled contract fixes subject cardinality to exactly one, omits source +metadata, and makes every resolve response non-cacheable. These are runtime +invariants rather than authoring switches. The authored profile and generated +Relay configuration are both covered by the signed project semantic and +closure digests. diff --git a/crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release/registry-stack.yaml b/crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release/registry-stack.yaml index debe2053a..1bc3e4843 100644 --- a/crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release/registry-stack.yaml +++ b/crates/registryctl/tests/fixtures/project-authoring/nia-attribute-release/registry-stack.yaml @@ -40,7 +40,6 @@ services: purpose: https://id.registrystack.org/solmara/purpose/esignet-identity-verification release_scope: population:identity_release subject: - input: individual_id source_field: legacy_nid id_type: national_id release_conditions: @@ -72,6 +71,4 @@ services: source_field: sex required: false sensitivity: personal - response: - max_age_seconds: 300 standards: { ogc_features: false, sp_dci: false } diff --git a/deny.toml b/deny.toml index cbe962c5c..5b2c68bf0 100644 --- a/deny.toml +++ b/deny.toml @@ -15,9 +15,10 @@ ignore = [ # through Crosswalk -> phonenumber -> postcard -> heapless 0.7. Phonenumber # uses postcard at build time and runtime, but heapless selects # `atomic-polyfill` only for AVR, riscv32i/riscv32imc, thumbv6m, and - # xtensa-esp32s2, not Registry Stack's supported service targets. Review - # when the chain removes it, before supporting an affected embedded target, - # or before making CEL mapping part of a hardened default runtime. + # xtensa-esp32s2, not Registry Stack's supported x86_64/aarch64 Linux or + # x86_64 macOS service targets. Reviewed 2026-07-25 for the canonical + # attribute-release runtime. Review when the chain removes it, before + # supporting an affected embedded target, or if the advisory scope changes. "RUSTSEC-2023-0089", # RUSTSEC-2024-0436: `paste` is an unmaintained build-time procedural macro # pulled in by Relay's DataFusion and Parquet graph. It does not remain in diff --git a/docs/site/scripts/relay-release-contract.test.mjs b/docs/site/scripts/relay-release-contract.test.mjs index 6c1468c2f..8ae4285c5 100644 --- a/docs/site/scripts/relay-release-contract.test.mjs +++ b/docs/site/scripts/relay-release-contract.test.mjs @@ -21,6 +21,8 @@ const stableIds = new Set([ 'csv-source-input', 'xlsx-source-input', 'json-aggregate-output', + 'attribute-release', + 'crosswalk-runtime', ]); const experimentalIds = new Set([ @@ -31,10 +33,11 @@ const experimentalIds = new Set([ 'standards-cel-mapping', 'sdmx-json-aggregate-output', 'csv-aggregate-output', - 'attribute-release', 'parquet-source-input', ]); +const issue487Ids = new Set(['attribute-release', 'crosswalk-runtime']); + async function readRepo(path) { return readFile(resolve(repoRoot, path), 'utf8'); } @@ -59,10 +62,15 @@ test('Relay 1.0 roster pins the approved stable and experimental surfaces', asyn ); for (const entry of roster) { - assert.equal(entry.decision_date, '2026-07-19', `${entry.id} decision date`); + const issue487 = issue487Ids.has(entry.id); + assert.equal( + entry.decision_date, + issue487 ? '2026-07-25' : '2026-07-19', + `${entry.id} decision date`, + ); assert.equal( entry.decision_reference, - 'https://github.com/registrystack/registry-stack/issues/305', + `https://github.com/registrystack/registry-stack/issues/${issue487 ? '487' : '305'}`, `${entry.id} decision reference`, ); assert.ok(entry.evidence, `${entry.id} evidence reference`); @@ -118,32 +126,54 @@ test('canonical Relay release, local image, and OpenAPI use the same feature set .filter((entry) => entry.canonical_release) .flatMap((entry) => entry.cargo_features), ); - assert.deepEqual(canonicalFeatures, new Set(), 'the approved 1.0 Relay feature list is empty'); + assert.deepEqual( + canonicalFeatures, + new Set(['attribute-release', 'crosswalk-runtime']), + 'the approved 1.0 Relay feature list must be exact', + ); const cargoToml = await readRepo('crates/registry-relay/Cargo.toml'); + const cargoFeatureSection = cargoToml.match(/\[features\]\n([\s\S]*?)\n\[/)?.[1]; + assert.ok(cargoFeatureSection, 'Relay Cargo.toml must declare a features table'); const declaredFeatures = new Set( - [...cargoToml.matchAll(/^([a-z][a-z0-9-]*)\s*=\s*\[/gm)].map((match) => match[1]), + [...cargoFeatureSection.matchAll(/^([a-z][a-z0-9-]*)\s*=\s*\[/gm)].map( + (match) => match[1], + ), ); for (const feature of roster.flatMap((entry) => entry.cargo_features)) { - assert.ok(declaredFeatures.has(feature), `experimental source feature ${feature} must remain`); + assert.ok(declaredFeatures.has(feature), `rostered source feature ${feature} must remain`); } + assert.deepEqual( + new Set(roster.flatMap((entry) => entry.cargo_features)), + new Set([...declaredFeatures].filter((feature) => feature !== 'default')), + 'every Relay Cargo feature must have one support-roster decision', + ); + assert.match( + cargoToml, + /^default = \["attribute-release"\]$/m, + 'the developer default must enable stable attribute release', + ); const dockerfile = await readRepo('crates/registry-relay/Dockerfile'); assert.match( dockerfile, - /^ARG REGISTRY_RELAY_FEATURES=""$/m, - 'the local production image must default to the canonical empty feature set', + /^ARG REGISTRY_RELAY_FEATURES="attribute-release,crosswalk-runtime"$/m, + 'the local production image must default to the canonical feature set', ); - const workflowPath = process.env.RELAY_RELEASE_WORKFLOW_PATH ?? '.github/workflows/release.yml'; - const workflow = isAbsolute(workflowPath) - ? await readFile(workflowPath, 'utf8') - : await readRepo(workflowPath); + const releaseRecipePath = + process.env.RELAY_RELEASE_WORKFLOW_PATH ?? 'release/scripts/build-release-binaries.sh'; + const releaseRecipe = isAbsolute(releaseRecipePath) + ? await readFile(releaseRecipePath, 'utf8') + : await readRepo(releaseRecipePath); + const relayBuild = releaseRecipe.match( + /-p registry-relay \\\n\s+--no-default-features \\\n\s+--features ([^\s'"]+)/, + ); + assert.ok(relayBuild, 'the release recipe must select an exact Relay feature set'); const workflowRelayFeatures = new Set( - [...workflow.matchAll(/--features\s+([^\s'"]+)/g)] - .flatMap((match) => match[1].split(',')) - .filter((feature) => feature.startsWith('registry-relay/')) - .map((feature) => feature.slice('registry-relay/'.length)), + relayBuild[1] + .split(',') + .map((feature) => feature.replace(/^registry-relay\//, '')), ); assert.deepEqual( workflowRelayFeatures, @@ -176,6 +206,14 @@ test('canonical Relay release, local image, and OpenAPI use the same feature set openapi.paths['/metadata/ogc/records'], 'stable link-free OGC Records metadata must remain in the pinned OpenAPI', ); + assert.ok( + openapi.paths['/v1/attribute-releases'], + 'stable attribute release discovery must appear in the pinned OpenAPI', + ); + assert.ok( + openapi.paths['/v1/attribute-releases/{profile_id}/versions/{version}/resolve'], + 'stable attribute release resolution must appear in the pinned OpenAPI', + ); const justfile = await readRepo('crates/registry-relay/justfile'); assert.match(justfile, /^\s*cargo test --all-features$/m, 'all-feature tests must remain enabled'); diff --git a/docs/site/src/content/docs/reference/apis/registry-relay.mdx b/docs/site/src/content/docs/reference/apis/registry-relay.mdx index f43cc6609..bce032043 100644 --- a/docs/site/src/content/docs/reference/apis/registry-relay.mdx +++ b/docs/site/src/content/docs/reference/apis/registry-relay.mdx @@ -64,10 +64,11 @@ For setup steps, scope strings, and credential configuration, see `api.require_purpose_header: true` on an entity or aggregate source makes the header mandatory for that operation. Calls that omit it are rejected with `400 auth.purpose_required`. - **Feature-gated surfaces.** The live OGC API Features, Records, and EDR adapters, SP DCI sync - routes, standards-CEL mapping, and attribute release are experimental and feature-frozen. + routes and standards-CEL mapping are experimental and feature-frozen. They are outside the 1.0 compatibility promise and excluded from the canonical release binary. - Their source and all-feature tests remain available. Relay does not issue response credentials - or host issuer DID documents. + Their source and all-feature tests remain available. Governed attribute release is stable and + included in the canonical binary. Relay does not issue response credentials or host issuer DID + documents. - **Link-free Records metadata.** Stable link-free OGC Records bodies under `/metadata/ogc/records` are portable metadata. They are separate from the experimental live adapter under `/ogc/v1/records`. diff --git a/docs/site/src/data/generated/relay-support.json b/docs/site/src/data/generated/relay-support.json index 94e721f59..ab42965f9 100644 --- a/docs/site/src/data/generated/relay-support.json +++ b/docs/site/src/data/generated/relay-support.json @@ -329,18 +329,35 @@ "id": "attribute-release", "name": "Attribute release", "category": "adapter", - "stability_tier": "experimental", - "support_owner": "none", - "feature_frozen": true, - "canonical_release": false, + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, "cargo_features": [ "attribute-release" ], - "openapi_policy": "excluded", - "decision_date": "2026-07-19", - "decision_reference": "https://github.com/registrystack/registry-stack/issues/305", + "openapi_policy": "included", + "decision_date": "2026-07-25", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/487", "evidence": "crates/registry-relay/tests/attribute_release_api.rs", - "notes": "Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise." + "notes": "Governed, purpose-bound, exactly-one-subject release is part of the canonical 1.0 contract." + }, + { + "id": "crosswalk-runtime", + "name": "Crosswalk runtime", + "category": "runtime", + "stability_tier": "stable", + "support_owner": "registry-relay", + "feature_frozen": false, + "canonical_release": true, + "cargo_features": [ + "crosswalk-runtime" + ], + "openapi_policy": "not_applicable", + "decision_date": "2026-07-25", + "decision_reference": "https://github.com/registrystack/registry-stack/issues/487", + "evidence": "crates/registry-relay/src/attribute_release/mod.rs", + "notes": "The canonical runtime dependency supports bounded attribute-release CEL evaluation." }, { "id": "parquet-source-input", diff --git a/docs/site/src/data/relay-support.yaml b/docs/site/src/data/relay-support.yaml index 95bd95d05..a7a1c066b 100644 --- a/docs/site/src/data/relay-support.yaml +++ b/docs/site/src/data/relay-support.yaml @@ -267,16 +267,29 @@ - id: attribute-release name: Attribute release category: adapter - stability_tier: experimental - support_owner: none - feature_frozen: true - canonical_release: false + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true cargo_features: [attribute-release] - openapi_policy: excluded - decision_date: "2026-07-19" - decision_reference: https://github.com/registrystack/registry-stack/issues/305 + openapi_policy: included + decision_date: "2026-07-25" + decision_reference: https://github.com/registrystack/registry-stack/issues/487 evidence: crates/registry-relay/tests/attribute_release_api.rs - notes: Source and all-feature tests remain, but the adapter is outside the 1.0 compatibility promise. + notes: Governed, purpose-bound, exactly-one-subject release is part of the canonical 1.0 contract. +- id: crosswalk-runtime + name: Crosswalk runtime + category: runtime + stability_tier: stable + support_owner: registry-relay + feature_frozen: false + canonical_release: true + cargo_features: [crosswalk-runtime] + openapi_policy: not_applicable + decision_date: "2026-07-25" + decision_reference: https://github.com/registrystack/registry-stack/issues/487 + evidence: crates/registry-relay/src/attribute_release/mod.rs + notes: The canonical runtime dependency supports bounded attribute-release CEL evaluation. - id: parquet-source-input name: Parquet source input category: source_decoder diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index 78eb7730c..0426d99e9 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -65,7 +65,8 @@ docker run --rm \ cargo build --release --locked \ -p registry-relay \ - --no-default-features + --no-default-features \ + --features registry-relay/attribute-release,registry-relay/crosswalk-runtime python3 release/scripts/check-release-relay-features.py target/release/registry-relay cp target/release/registry-relay "dist/bin/registry-relay-${RELEASE_TAG}-linux-amd64" cp target/release/registry-relay-rhai-worker "dist/bin/registry-relay-rhai-worker-${RELEASE_TAG}-linux-amd64" diff --git a/release/scripts/build-release-image.sh b/release/scripts/build-release-image.sh index 51da6c650..d40f2ee93 100755 --- a/release/scripts/build-release-image.sh +++ b/release/scripts/build-release-image.sh @@ -33,6 +33,13 @@ case "${name}" in ;; esac +product_label_args=() +if [[ "${name}" == "registry-relay" ]]; then + product_label_args+=( + --label "org.registrystack.registry-relay.features=attribute-release,crosswalk-runtime" + ) +fi + cache_args=() if [[ -n "${RELEASE_IMAGE_CACHE_FROM:-}" ]]; then cache_args+=(--cache-from "${RELEASE_IMAGE_CACHE_FROM}") @@ -160,6 +167,7 @@ docker buildx build \ --label "org.opencontainers.image.source=${source_label}" \ --label "org.opencontainers.image.revision=${revision_label}" \ --label "org.opencontainers.image.version=${version_label}" \ + "${product_label_args[@]}" \ --build-arg "SOURCE_DATE_EPOCH=${source_date_epoch}" \ --metadata-file "${metadata_file}" \ "${no_cache_args[@]}" \ diff --git a/release/scripts/check-release-image-oci-labels.py b/release/scripts/check-release-image-oci-labels.py index 73bfe0e18..19619fcd0 100755 --- a/release/scripts/check-release-image-oci-labels.py +++ b/release/scripts/check-release-image-oci-labels.py @@ -169,6 +169,7 @@ def require_oci_labels( image_ref: str, config: dict[str, Any], expected: dict[str, str], + expected_labels: dict[str, str], ) -> None: if "Labels" not in config: raise CheckError( @@ -197,6 +198,33 @@ def require_oci_labels( f"{actual!r}; expected exactly {wanted!r}" ) + for label, wanted in expected_labels.items(): + if label not in labels: + raise CheckError( + f"image config Labels for {image_ref!r} is missing required " + f"label {label!r}" + ) + actual = labels[label] + if actual != wanted: + raise CheckError( + f"image label {label!r} for {image_ref!r} has value " + f"{actual!r}; expected exactly {wanted!r}" + ) + + +def parse_expected_labels(values: Sequence[str]) -> dict[str, str]: + expected: dict[str, str] = {} + for value in values: + label, separator, wanted = value.partition("=") + if not separator or not label: + raise CheckError( + f"expected label must have the form KEY=VALUE, got {value!r}" + ) + if label in expected: + raise CheckError(f"expected label {label!r} was provided more than once") + expected[label] = wanted + return expected + def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser( @@ -206,6 +234,13 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: parser.add_argument("--source", required=True, help="Expected OCI source label") parser.add_argument("--revision", required=True, help="Expected OCI revision label") parser.add_argument("--version", required=True, help="Expected OCI version label") + parser.add_argument( + "--expected-label", + action="append", + default=[], + metavar="KEY=VALUE", + help="Additional image label and exact value to verify; repeatable", + ) parser.add_argument( "--format-template", default=DEFAULT_FORMAT_TEMPLATE, @@ -218,6 +253,7 @@ def main(argv: Sequence[str] | None = None) -> int: args = parse_args(argv) try: config = inspect_image_config(args.image_ref, args.format_template) + expected_labels = parse_expected_labels(args.expected_label) require_oci_labels( args.image_ref, config, @@ -226,6 +262,7 @@ def main(argv: Sequence[str] | None = None) -> int: "revision": args.revision, "version": args.version, }, + expected_labels, ) except CheckError as error: print(f"error: {error}", file=sys.stderr) diff --git a/release/scripts/check-release-relay-features.py b/release/scripts/check-release-relay-features.py index 5b4b48c80..4e9d74180 100755 --- a/release/scripts/check-release-relay-features.py +++ b/release/scripts/check-release-relay-features.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Verify that the release Relay binary keeps optional API surfaces disabled.""" +"""Verify the exact canonical Relay release feature profile.""" from __future__ import annotations @@ -8,10 +8,13 @@ from pathlib import Path -DISABLED_FEATURE_MARKERS: dict[str, bytes] = { +ENABLED_FEATURE_MARKERS: dict[str, bytes] = { "attribute-release": ( - b"attribute_release_profiles require the attribute-release feature" + b"/v1/attribute-releases/{profile_id}/versions/{version}/resolve" ), +} + +DISABLED_FEATURE_MARKERS: dict[str, bytes] = { "ogcapi-features": ( b"entity declares OGC API Features spatial config but binary was built " b"without the ogcapi-features feature" @@ -22,6 +25,12 @@ ), } +FORBIDDEN_FEATURE_MARKERS: dict[str, bytes] = { + "attribute-release": ( + b"attribute_release_profiles require the attribute-release feature" + ), +} + class FeatureCheckError(ValueError): pass @@ -35,15 +44,37 @@ def check_binary(path: Path) -> None: if not payload.startswith(b"\x7fELF"): raise FeatureCheckError(f"release Relay binary is not an ELF executable: {path}") - missing = [ + missing_enabled = [ + feature + for feature, marker in ENABLED_FEATURE_MARKERS.items() + if marker not in payload + ] + if missing_enabled: + raise FeatureCheckError( + "release Relay binary does not prove these canonical features are " + f"enabled: {', '.join(missing_enabled)}" + ) + + missing_disabled = [ feature for feature, marker in DISABLED_FEATURE_MARKERS.items() if marker not in payload ] - if missing: + if missing_disabled: raise FeatureCheckError( "release Relay binary does not prove these optional features are " - f"disabled: {', '.join(missing)}" + f"disabled: {', '.join(missing_disabled)}" + ) + + forbidden = [ + feature + for feature, marker in FORBIDDEN_FEATURE_MARKERS.items() + if marker in payload + ] + if forbidden: + raise FeatureCheckError( + "release Relay binary contains feature-disabled markers for canonical " + f"features: {', '.join(forbidden)}" ) @@ -60,8 +91,9 @@ def main(argv: list[str] | None = None) -> int: except FeatureCheckError as error: print(f"release Relay feature check failed: {error}", file=sys.stderr) return 1 - features = ", ".join(DISABLED_FEATURE_MARKERS) - print(f"verified disabled release Relay features: {features}") + enabled = ", ".join(ENABLED_FEATURE_MARKERS) + disabled = ", ".join(DISABLED_FEATURE_MARKERS) + print(f"verified canonical release Relay features: enabled={enabled}; disabled={disabled}") return 0 diff --git a/release/scripts/smoke-release-image-oci-labels.sh b/release/scripts/smoke-release-image-oci-labels.sh index 87dd8f789..6e40d1697 100755 --- a/release/scripts/smoke-release-image-oci-labels.sh +++ b/release/scripts/smoke-release-image-oci-labels.sh @@ -14,6 +14,7 @@ revision_label="0123456789abcdef0123456789abcdef01234567" wrong_revision_label="89abcdef0123456789abcdef0123456789abcdef" version_label="v0.0.0-oci-label-smoke" source_date_epoch=0 +relay_features_label="org.registrystack.registry-relay.features=attribute-release,crosswalk-runtime" tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/registry-stack-oci-labels.XXXXXX")" smoke_builder="registry-stack-release-smoke-$$-${RANDOM}" @@ -102,10 +103,15 @@ for image in "${images[@]}"; do "${context_dir}/dist/image-bin/registry-notary-cel-worker" \ "${context_dir}/LICENSE" build_layout "${image}" "${first_layout}" "${revision_label}" "${version_label}" + expected_label_args=() + if [[ "${image}" == "registry-relay" ]]; then + expected_label_args+=(--expected-label "${relay_features_label}") + fi python3 "${checker}" "oci-layout://${first_layout}" \ --source "${source_label}" \ --revision "${revision_label}" \ - --version "${version_label}" + --version "${version_label}" \ + "${expected_label_args[@]}" # Source mtimes and uncached RUN timestamps must not affect release-owned # layers. The exporter also normalizes inherited layers to the fixed epoch. @@ -128,6 +134,7 @@ expect_failure "lower-case image config template" \ --source "${source_label}" \ --revision "${revision_label}" \ --version "${version_label}" \ + --expected-label "${relay_features_label}" \ --format-template '{{json .Image.config}}' build_negative_layout "${relay_dockerfile}" "${missing_version_layout}" "${revision_label}" @@ -135,7 +142,8 @@ expect_failure "image missing the version label" \ python3 "${checker}" "oci-layout://${missing_version_layout}" \ --source "${source_label}" \ --revision "${revision_label}" \ - --version "${version_label}" + --version "${version_label}" \ + --expected-label "${relay_features_label}" build_negative_layout "${relay_dockerfile}" "${wrong_revision_layout}" \ "${wrong_revision_label}" "${version_label}" @@ -143,7 +151,8 @@ expect_failure "image with the wrong revision label" \ python3 "${checker}" "oci-layout://${wrong_revision_layout}" \ --source "${source_label}" \ --revision "${revision_label}" \ - --version "${version_label}" + --version "${version_label}" \ + --expected-label "${relay_features_label}" python3 "${layout_comparator}" "${correct_layout}" "${wrong_revision_layout}" \ --rootfs-only diff --git a/release/scripts/test_check_release_image_oci_labels.py b/release/scripts/test_check_release_image_oci_labels.py index 368dac597..c0e78daf7 100644 --- a/release/scripts/test_check_release_image_oci_labels.py +++ b/release/scripts/test_check_release_image_oci_labels.py @@ -23,6 +23,8 @@ SOURCE = "https://github.com/registrystack/registry-stack" REVISION = "b" * 40 VERSION = "v0.12.0" +RELAY_FEATURE_LABEL = "org.registrystack.registry-relay.features" +RELAY_FEATURES = "attribute-release,crosswalk-runtime" BUILDKIT_IMAGE = ( "moby/buildkit:v0.31.2@sha256:" "2f5adac4ecd194d9f8c10b7b5d7bceb5186853db1b26e5abd3a657af0b7e26ec" @@ -317,6 +319,57 @@ def test_wrong_label_value_is_rejected(self) -> None: self.assertIn("org.opencontainers.image.revision", stderr) self.assertIn("expected exactly", stderr) + def test_additional_expected_label_is_verified(self) -> None: + labels = { + "org.opencontainers.image.source": SOURCE, + "org.opencontainers.image.revision": REVISION, + "org.opencontainers.image.version": VERSION, + RELAY_FEATURE_LABEL: RELAY_FEATURES, + } + + result, _, stderr, _ = self.run_with_inspect( + stdout=config_json(labels), + extra_args=[ + "--expected-label", + f"{RELAY_FEATURE_LABEL}={RELAY_FEATURES}", + ], + ) + + self.assertEqual(0, result, stderr) + + def test_missing_additional_expected_label_is_rejected(self) -> None: + labels = { + "org.opencontainers.image.source": SOURCE, + "org.opencontainers.image.revision": REVISION, + "org.opencontainers.image.version": VERSION, + } + + result, _, stderr, _ = self.run_with_inspect( + stdout=config_json(labels), + extra_args=[ + "--expected-label", + f"{RELAY_FEATURE_LABEL}={RELAY_FEATURES}", + ], + ) + + self.assertEqual(1, result) + self.assertIn(RELAY_FEATURE_LABEL, stderr) + + def test_malformed_additional_expected_label_is_rejected(self) -> None: + result, _, stderr, _ = self.run_with_inspect( + stdout=config_json( + { + "org.opencontainers.image.source": SOURCE, + "org.opencontainers.image.revision": REVISION, + "org.opencontainers.image.version": VERSION, + } + ), + extra_args=["--expected-label", "missing-separator"], + ) + + self.assertEqual(1, result) + self.assertIn("KEY=VALUE", stderr) + def test_format_template_override_is_passed_to_docker(self) -> None: result, _, stderr, run = self.run_with_inspect( returncode=1, @@ -401,6 +454,10 @@ def test_reused_builder_requires_pinned_standard_buildkit_container(self) -> Non self.assertEqual(0, result.returncode, result.stderr) self.assertNotIn("--provenance=false", result.stdout) + self.assertIn( + f"<{RELAY_FEATURE_LABEL}={RELAY_FEATURES}>", + result.stdout, + ) def test_retained_oci_layout_disables_timestamped_provenance(self) -> None: result = self.run_wrapper( @@ -599,6 +656,21 @@ def read_calls(path: Path) -> list[list[str]]: for layout in inspected_layouts }, ) + relay_checks = [ + call + for call in python_calls + if call + and call[0].endswith("check-release-image-oci-labels.py") + and len(call) > 1 + and "correct-registry-relay-first" in call[1] + and "--format-template" not in call + ] + self.assertEqual(1, len(relay_checks)) + self.assertIn("--expected-label", relay_checks[0]) + self.assertIn( + f"{RELAY_FEATURE_LABEL}={RELAY_FEATURES}", + relay_checks[0], + ) comparisons = [ call for call in python_calls diff --git a/release/scripts/test_check_release_relay_features.py b/release/scripts/test_check_release_relay_features.py index f8bd049bf..aa1657a84 100644 --- a/release/scripts/test_check_release_relay_features.py +++ b/release/scripts/test_check_release_relay_features.py @@ -28,14 +28,36 @@ def write_binary(self, root: Path, markers: list[bytes]) -> Path: binary.write_bytes(b"\x7fELF\x02\x01" + b"\x00".join(markers)) return binary - def test_accepts_binary_with_all_disabled_feature_markers(self) -> None: + def canonical_markers(self) -> list[bytes]: + return [ + *self.module.ENABLED_FEATURE_MARKERS.values(), + *self.module.DISABLED_FEATURE_MARKERS.values(), + ] + + def test_accepts_binary_with_exact_canonical_feature_markers(self) -> None: with tempfile.TemporaryDirectory() as directory: - binary = self.write_binary( - Path(directory), list(self.module.DISABLED_FEATURE_MARKERS.values()) - ) + binary = self.write_binary(Path(directory), self.canonical_markers()) self.module.check_binary(binary) + def test_rejects_each_missing_enabled_feature_marker(self) -> None: + markers = self.module.ENABLED_FEATURE_MARKERS + for missing_feature, missing_marker in markers.items(): + with self.subTest(feature=missing_feature), tempfile.TemporaryDirectory() as directory: + binary = self.write_binary( + Path(directory), + [ + marker + for marker in self.canonical_markers() + if marker != missing_marker + ], + ) + + with self.assertRaisesRegex( + self.module.FeatureCheckError, missing_feature + ): + self.module.check_binary(binary) + def test_rejects_each_missing_disabled_feature_marker(self) -> None: markers = self.module.DISABLED_FEATURE_MARKERS for missing_feature in markers: @@ -43,9 +65,12 @@ def test_rejects_each_missing_disabled_feature_marker(self) -> None: binary = self.write_binary( Path(directory), [ - marker - for feature, marker in markers.items() - if feature != missing_feature + *self.module.ENABLED_FEATURE_MARKERS.values(), + *[ + marker + for feature, marker in markers.items() + if feature != missing_feature + ], ], ) @@ -64,7 +89,7 @@ def test_rejects_non_elf_input(self) -> None: def test_rejects_truncated_marker(self) -> None: with tempfile.TemporaryDirectory() as directory: - markers = list(self.module.DISABLED_FEATURE_MARKERS.values()) + markers = self.canonical_markers() markers[-1] = markers[-1][:-1] binary = self.write_binary(Path(directory), markers) @@ -77,7 +102,7 @@ def test_reports_every_missing_marker(self) -> None: with tempfile.TemporaryDirectory() as directory: binary = self.write_binary( Path(directory), - [self.module.DISABLED_FEATURE_MARKERS["attribute-release"]], + list(self.module.ENABLED_FEATURE_MARKERS.values()), ) with self.assertRaises(self.module.FeatureCheckError) as context: @@ -86,6 +111,21 @@ def test_reports_every_missing_marker(self) -> None: self.assertIn("ogcapi-features", message) self.assertIn("spdci-api-standards", message) + def test_rejects_feature_disabled_marker_for_canonical_feature(self) -> None: + with tempfile.TemporaryDirectory() as directory: + binary = self.write_binary( + Path(directory), + [ + *self.canonical_markers(), + self.module.FORBIDDEN_FEATURE_MARKERS["attribute-release"], + ], + ) + + with self.assertRaisesRegex( + self.module.FeatureCheckError, "attribute-release" + ): + self.module.check_binary(binary) + def test_rejects_missing_binary(self) -> None: with tempfile.TemporaryDirectory() as directory: with self.assertRaisesRegex( diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index f652b3f81..b1898d9b0 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -270,6 +270,11 @@ def test_release_images_publish_and_executably_verify_oci_labels(self) -> None: self.assertIn( '--version "${{ needs.validate.outputs.version }}"', verification_job ) + self.assertIn( + '--expected-label "org.registrystack.registry-relay.features=' + 'attribute-release,crosswalk-runtime"', + verification_job, + ) self.assertNotIn("{{json .Image.config}}", workflow) def test_release_cargo_cache_is_scoped_to_builder_image(self) -> None: diff --git a/schemas/registry-relay.config.schema.json b/schemas/registry-relay.config.schema.json index c4f022660..8eff0e75f 100644 --- a/schemas/registry-relay.config.schema.json +++ b/schemas/registry-relay.config.schema.json @@ -409,7 +409,7 @@ }, "AttributeReleaseProfile": { "additionalProperties": false, - "description": "A governed identity attribute-release profile. A profile is a\nprojection-limited, exactly-one-subject lookup that maps a configured set of\nsource fields (or CEL-computed expressions) into a minimised\nOIDC/UserInfo-style claim bundle. It is *optionally* purpose-bound: a profile\nthat declares a `purpose` requires a matching `data-purpose` at resolve time;\none that omits it does not. Identified globally by the `(id, version)` pair;\nboth are required path segments at resolve time.", + "description": "A governed identity attribute-release profile. A profile is a\nprojection-limited, exactly-one-subject lookup that maps a configured set of\nsource fields (or CEL-computed expressions) into a minimised\nOIDC/UserInfo-style claim bundle. Every profile is purpose-bound and requires\na matching `data-purpose` at resolve time. Identified globally by the\n`(id, version)` pair; both are required path segments at resolve time.", "properties": { "claims": { "description": "Claims released on success. Non-empty; at least one `required`.", @@ -430,12 +430,8 @@ "type": "string" }, "purpose": { - "default": null, - "description": "Data-purpose this profile is bound to. Required (and must be a member\nof the entity's `governed_policy.permitted_purposes`) when the backing\nentity declares any permitted purposes.", - "type": [ - "string", - "null" - ] + "description": "Data-purpose this profile is bound to. When the backing entity declares\n`governed_policy.permitted_purposes`, this must be a member.", + "type": "string" }, "release_conditions": { "anyOf": [ @@ -446,10 +442,10 @@ "type": "null" } ], - "description": "Optional CEL release-condition gate evaluated before projection." + "description": "Optional CEL release-condition gate evaluated over the redacted row." }, "release_scope": { - "description": "Dataset-bound scope a caller must hold to invoke this release. Must\ndiffer from the entity's `read_scope`.", + "description": "Dataset-bound scope a caller must hold to invoke this release. The\nstable profile is exactly `:identity_release`.", "type": "string" }, "response": { @@ -475,6 +471,7 @@ "required": [ "id", "version", + "purpose", "release_scope", "subject", "claims" @@ -2485,11 +2482,6 @@ ], "description": "Optional privacy sensitivity label." }, - "shareable": { - "default": true, - "description": "Whether the claim may be shared downstream. Defaults to true.", - "type": "boolean" - }, "source_field": { "default": null, "description": "Source field projected into the claim. XOR with `expression`.", @@ -2506,16 +2498,8 @@ }, "ReleaseConditionsConfig": { "additionalProperties": false, - "description": "CEL release-condition gate. When present, the predicate must hold before\nany claim is projected; failure fails closed (subject denied).", + "description": "CEL release-condition gate. When present, the predicate must hold over the\ngoverned-redacted row before any claim is projected; failure fails closed.", "properties": { - "denied_code": { - "default": null, - "description": "Optional internal audit code for a release-condition denial.", - "type": [ - "string", - "null" - ] - }, "expression": { "$ref": "#/$defs/ReleaseExpressionConfig" } @@ -2546,17 +2530,6 @@ "default": false, "description": "Whether to include profile-sourced metadata in the response body.", "type": "boolean" - }, - "max_age_seconds": { - "default": null, - "description": "Optional cache lifetime hint for the released bundle, in seconds.", - "format": "uint64", - "maximum": 18446744073709551615, - "minimum": 0, - "type": [ - "integer", - "null" - ] } }, "type": "object" @@ -2565,20 +2538,8 @@ "additionalProperties": false, "description": "Subject-identification controls for an attribute-release profile.", "properties": { - "cardinality": { - "$ref": "#/$defs/SubjectCardinality", - "description": "Expected subject cardinality. Defaults to exactly one." - }, "id_type": { - "default": null, - "description": "Optional accepted identifier type label.", - "type": [ - "string", - "null" - ] - }, - "input": { - "description": "Request input that carries the subject identifier.", + "description": "Accepted identifier type label.", "type": "string" }, "source_field": { @@ -2587,8 +2548,8 @@ } }, "required": [ - "input", - "source_field" + "source_field", + "id_type" ], "type": "object" }, @@ -3276,14 +3237,6 @@ }, "type": "object" }, - "SubjectCardinality": { - "description": "Expected number of subjects a release lookup may match.", - "enum": [ - "one", - "many" - ], - "type": "string" - }, "Suppression": { "description": "Disclosure suppression strategy.", "oneOf": [ From fce408d6576316401ce89ffb7ed0f0bcf598bd71 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 25 Jul 2026 22:52:17 +0700 Subject: [PATCH 2/9] fix(relay): align assurance with default features Signed-off-by: Jeremi Joslin --- .../registry-relay/docs/security-assurance.md | 9 ++- .../scripts/check_security_assurance.py | 69 ++++++++++++++++--- .../tests/security_assurance_check_test.py | 50 +++++++++++++- 3 files changed, 115 insertions(+), 13 deletions(-) diff --git a/crates/registry-relay/docs/security-assurance.md b/crates/registry-relay/docs/security-assurance.md index 1bea90734..ee24c984f 100644 --- a/crates/registry-relay/docs/security-assurance.md +++ b/crates/registry-relay/docs/security-assurance.md @@ -176,9 +176,12 @@ rules when installed. Endpoint exposure is checked in three directions: route inventory to manifest, manifest to route inventory, and Rust Axum route declarations to -route inventory. Protected public routes with non-optional features are also -covered by `tests/security_assurance_surface.rs`, which builds the production -public app and verifies the manifest routes are actually mounted behind auth. +route inventory. A feature-gated route may be stable only when its Cargo +feature is enabled by default; optional feature gates remain experimental. +Protected public routes without a feature gate or behind a default feature are +also covered by `tests/security_assurance_surface.rs`, which builds the +production public app and verifies the manifest routes are actually mounted +behind auth. Hadolint ignores `DL3022` because the Dockerfile intentionally copies from named external build contexts. It also ignores `DL3008` for the apt package diff --git a/crates/registry-relay/scripts/check_security_assurance.py b/crates/registry-relay/scripts/check_security_assurance.py index b1402df23..66d212788 100755 --- a/crates/registry-relay/scripts/check_security_assurance.py +++ b/crates/registry-relay/scripts/check_security_assurance.py @@ -8,6 +8,7 @@ import json import re import sys +import tomllib from pathlib import Path @@ -84,6 +85,40 @@ def load_json(path: Path) -> object: fail(f"{path.relative_to(ROOT)} is not valid JSON: {exc}") +def load_default_features() -> set[str]: + path = ROOT / "Cargo.toml" + try: + document = tomllib.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError: + fail("missing required file: Cargo.toml") + except tomllib.TOMLDecodeError as exc: + fail(f"Cargo.toml is not valid TOML: {exc}") + + features = document.get("features") + if not isinstance(features, dict): + fail("Cargo.toml must contain a [features] table") + defaults = features.get("default") + if not isinstance(defaults, list) or not all( + isinstance(feature, str) for feature in defaults + ): + fail("Cargo.toml features.default must be a list of feature names") + + enabled: set[str] = set() + pending = list(defaults) + while pending: + feature = pending.pop() + if feature in enabled: + continue + members = features.get(feature) + if not isinstance(members, list) or not all( + isinstance(member, str) for member in members + ): + fail(f"Cargo.toml default feature {feature} must name a feature list") + enabled.add(feature) + pending.extend(member for member in members if member in features) + return enabled + + def fail(message: str) -> None: print(f"security assurance check failed: {message}", file=sys.stderr) raise SystemExit(1) @@ -126,6 +161,7 @@ def validate_manifest() -> None: manifest = load_json(SECURITY_DIR / "exposure-manifest.json") inventory = load_json(SECURITY_DIR / "route-inventory.json") allowlist = load_allowlist(SECURITY_DIR / "auth-none-allowlist.yml") + default_features = load_default_features() if not isinstance(manifest, dict) or manifest.get("service") != "registry-relay": fail("exposure-manifest.json must describe service registry-relay") @@ -150,7 +186,7 @@ def validate_manifest() -> None: check_value(entry, "stability", STABILITY) check_value(entry, "data_classification", DATA) check_value(entry, "source", SOURCES) - validate_stability(entry) + validate_stability(entry, default_features) if entry["method"] not in {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}: fail(f"{entry['path']} has invalid method {entry['method']}") if not isinstance(entry["scopes"], list): @@ -199,11 +235,20 @@ def validate_manifest() -> None: validate_route_sources(inventory) -def validate_stability(entry: dict) -> None: - if entry["feature"] is not None and entry["stability"] != "experimental": +def validate_stability( + entry: dict, default_features: set[str] | None = None +) -> None: + default_features = default_features or set() + feature = entry["feature"] + if ( + feature is not None + and feature not in default_features + and entry["stability"] != "experimental" + ): fail( - f"{entry['path']} is feature-gated but has stability " - f"{entry['stability']}; the 1.0 optional surfaces are experimental" + f"{entry['path']} is gated by optional feature {feature} but has " + f"stability {entry['stability']}; the 1.0 optional surfaces are " + "experimental" ) if key(entry) in CORE_STABLE_ROUTE_KEYS and entry["stability"] != "stable": fail( @@ -413,6 +458,7 @@ def check_openapi_strategy() -> None: def check_openapi_manifest_coverage(path: Path) -> None: manifest = load_json(SECURITY_DIR / "exposure-manifest.json") document = load_json(path) + default_features = load_default_features() if not isinstance(manifest, dict) or not isinstance(document, dict): fail("OpenAPI coverage inputs must be JSON objects") endpoints = manifest.get("endpoints") @@ -432,14 +478,16 @@ def check_openapi_manifest_coverage(path: Path) -> None: fail("endpoint entries must be objects") op_key = (openapi_path_shape(str(entry.get("path"))), str(entry.get("method"))) manifest_ops.setdefault(op_key, []).append(entry) - if entry.get("openapi") is True and is_default_openapi_entry(entry): + if entry.get("openapi") is True and is_default_openapi_entry( + entry, default_features + ): manifest_openapi_ops.add(op_key) missing = sorted( (entry["method"], entry["path"]) for entry in endpoints if entry.get("openapi") - and is_default_openapi_entry(entry) + and is_default_openapi_entry(entry, default_features) and (openapi_path_shape(entry["path"]), entry["method"]) not in openapi_op_keys ) if missing: @@ -478,8 +526,11 @@ def check_openapi_manifest_coverage(path: Path) -> None: ) -def is_default_openapi_entry(entry: dict) -> bool: - return entry.get("feature") is None +def is_default_openapi_entry( + entry: dict, default_features: set[str] | None = None +) -> bool: + feature = entry.get("feature") + return feature is None or feature in (default_features or set()) def openapi_path_shape(path: str) -> str: diff --git a/crates/registry-relay/tests/security_assurance_check_test.py b/crates/registry-relay/tests/security_assurance_check_test.py index 80ca49ec3..ffd59a053 100644 --- a/crates/registry-relay/tests/security_assurance_check_test.py +++ b/crates/registry-relay/tests/security_assurance_check_test.py @@ -39,6 +39,7 @@ def setUp(self): "openapi": "3.0.3", "paths": {"/x": {"get": {}}}, })) + self.write_features() self.old_root = self.module.ROOT self.old_security = self.module.SECURITY_DIR self.module.ROOT = self.root @@ -67,6 +68,17 @@ def write_contracts(self, manifest_entry): "endpoints": [manifest_entry], })) + def write_features(self, default=None): + default = default or [] + rendered_default = json.dumps(default) + (self.root / "Cargo.toml").write_text( + "[features]\n" + f"default = {rendered_default}\n" + 'attribute-release = ["crosswalk-runtime"]\n' + "crosswalk-runtime = []\n" + "ogcapi-features = []\n" + ) + def entry(self, **overrides): base = { "service": "registry-relay", @@ -94,7 +106,7 @@ def test_valid_contract_passes(self): self.write_contracts(self.entry()) self.module.validate_manifest() - def test_feature_gated_endpoint_must_remain_experimental(self): + def test_optional_feature_gated_endpoint_must_remain_experimental(self): self.write_contracts(self.entry(feature="ogcapi-features", stability="stable")) with self.assertRaises(SystemExit): self.module.validate_manifest() @@ -104,6 +116,22 @@ def test_feature_gated_endpoint_must_remain_experimental(self): ) self.module.validate_manifest() + def test_default_feature_gated_endpoint_may_be_stable(self): + self.write_features(["attribute-release"]) + self.write_contracts( + self.entry(feature="attribute-release", stability="stable") + ) + + self.module.validate_manifest() + + def test_default_feature_expansion_includes_local_feature_dependencies(self): + self.write_features(["attribute-release"]) + + self.assertEqual( + self.module.load_default_features(), + {"attribute-release", "crosswalk-runtime"}, + ) + def test_core_record_read_routes_must_remain_stable(self): expected = { ( @@ -228,6 +256,26 @@ def test_openapi_true_feature_gated_operation_is_not_required_in_default_artifac self.root / "openapi" / "registry-relay.openapi.json" ) + def test_openapi_true_default_feature_operation_is_required_in_default_artifact(self): + self.write_features(["attribute-release"]) + (self.root / "security" / "exposure-manifest.json").write_text(json.dumps({ + "version": 1, + "service": "registry-relay", + "endpoints": [self.entry(feature="attribute-release", openapi=True)], + })) + self.module.check_openapi_manifest_coverage( + self.root / "openapi" / "registry-relay.openapi.json" + ) + + (self.root / "openapi" / "registry-relay.openapi.json").write_text(json.dumps({ + "openapi": "3.0.3", + "paths": {}, + })) + with self.assertRaises(SystemExit): + self.module.check_openapi_manifest_coverage( + self.root / "openapi" / "registry-relay.openapi.json" + ) + def test_feature_gated_operation_present_in_default_openapi_fails(self): (self.root / "security" / "exposure-manifest.json").write_text(json.dumps({ "version": 1, From ad7fad501f1909b505230b39d0b8b9e59312bddb Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 25 Jul 2026 23:20:37 +0700 Subject: [PATCH 3/9] fix(relay): address promotion review feedback Signed-off-by: Jeremi Joslin --- .../openapi/registry-relay.openapi.json | 16 +++++- crates/registry-relay/src/api/openapi.rs | 52 +++++++++++++++++-- .../src/attribute_release/mod.rs | 24 ++++++++- .../src/project_authoring/compiler/notary.rs | 13 ++++- .../src/project_authoring/tests.rs | 8 +++ 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/crates/registry-relay/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index 0d56a5712..6c9a1ff27 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -5349,6 +5349,18 @@ "type": "string" } }, + { + "description": "Purpose of use. The value must exactly match the selected attribute-release profile's purpose and is checked before any source read. A missing value returns `400 auth.purpose_required`; a mismatched value returns `403 auth.purpose_denied`. Header names are case-insensitive (`Data-Purpose` and `data-purpose` are equivalent).", + "example": "identity_verification", + "in": "header", + "name": "Data-Purpose", + "required": true, + "schema": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, { "description": "Optional PDP trust context. Relay passes this value to policy evaluation only when the authenticated principal has the exact value-bound `registry:trust:legal_basis:` scope. Without that scope, Relay treats the header as absent. Header names are case-insensitive.", "in": "header", @@ -5479,7 +5491,7 @@ } } }, - "description": "Invalid request: malformed body, unknown id_type, empty claims list, or unsupported media type." + "description": "Invalid request: missing Data-Purpose header, malformed body, unknown id_type, empty claims list, or unsupported media type." }, "401": { "content": { @@ -5499,7 +5511,7 @@ } } }, - "description": "Subject denied: not found, ambiguous, release condition not met, or required claim unavailable. The response does not distinguish these cases." + "description": "Purpose denied, or subject denied because it was not found, was ambiguous, failed the release condition, or lacked a required claim. The response does not distinguish subject-denial cases." }, "404": { "content": { diff --git a/crates/registry-relay/src/api/openapi.rs b/crates/registry-relay/src/api/openapi.rs index 8b77d1795..6fa7c0af6 100644 --- a/crates/registry-relay/src/api/openapi.rs +++ b/crates/registry-relay/src/api/openapi.rs @@ -4151,14 +4151,14 @@ fn insert_attribute_release_paths(paths: &mut Map) { } }, "400": problem_response( - "Invalid request: malformed body, unknown id_type, empty claims list, \ - or unsupported media type." + "Invalid request: missing Data-Purpose header, malformed body, unknown \ + id_type, empty claims list, or unsupported media type." ), "401": problem_response("Missing or invalid bearer credential."), "403": problem_response( - "Subject denied: not found, ambiguous, release condition not met, \ - or required claim unavailable. The response does not distinguish \ - these cases." + "Purpose denied, or subject denied because it was not found, was \ + ambiguous, failed the release condition, or lacked a required claim. \ + The response does not distinguish subject-denial cases." ), "404": problem_response( "Profile not found or not visible to the authenticated principal. \ @@ -4170,6 +4170,12 @@ fn insert_attribute_release_paths(paths: &mut Map) { } }), ); + add_header_parameter( + paths, + "/v1/attribute-releases/{profile_id}/versions/{version}/resolve", + "post", + attribute_release_purpose_header_parameter(), + ); add_value_bound_trust_header_parameters( paths, "/v1/attribute-releases/{profile_id}/versions/{version}/resolve", @@ -5146,6 +5152,25 @@ fn purpose_header_parameter_with_required(required: bool) -> Value { }) } +fn attribute_release_purpose_header_parameter() -> Value { + json!({ + "name": "Data-Purpose", + "in": "header", + "required": true, + "description": "Purpose of use. The value must exactly match the selected \ + attribute-release profile's purpose and is checked before any source \ + read. A missing value returns `400 auth.purpose_required`; a mismatched \ + value returns `403 auth.purpose_denied`. Header names are \ + case-insensitive (`Data-Purpose` and `data-purpose` are equivalent).", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "example": "identity_verification", + }) +} + fn value_bound_trust_header_parameter(name: &str, scope_field: &str) -> Value { json!({ "name": name, @@ -6411,6 +6436,23 @@ mod tests { resolve_op["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], "#/components/schemas/AttributeReleaseResolveResponse" ); + let purpose = resolve_op["parameters"] + .as_array() + .expect("resolve parameters") + .iter() + .find(|parameter| parameter["name"] == "Data-Purpose") + .expect("resolve must declare Data-Purpose"); + assert_eq!(purpose["in"], "header"); + assert_eq!(purpose["required"], true); + assert_eq!(purpose["schema"]["minLength"], 1); + assert_eq!(purpose["schema"]["maxLength"], 256); + assert!( + purpose["description"] + .as_str() + .expect("purpose description") + .contains("exactly match"), + "purpose parameter must document profile binding" + ); assert_value_bound_trust_headers(resolve_op); // Standard denial responses must be present assert!( diff --git a/crates/registry-relay/src/attribute_release/mod.rs b/crates/registry-relay/src/attribute_release/mod.rs index a162e6897..0a49509be 100644 --- a/crates/registry-relay/src/attribute_release/mod.rs +++ b/crates/registry-relay/src/attribute_release/mod.rs @@ -303,8 +303,14 @@ mod enabled { while bytes.get(member_index).is_some_and(u8::is_ascii_whitespace) { member_index += 1; } - if &expression[start..index] == "context" - || matches!(bytes.get(member_index), Some(b'.' | b'[')) + let mut root_index = start; + while root_index > 0 && bytes[root_index - 1].is_ascii_whitespace() { + root_index -= 1; + } + let follows_member_access = root_index > 0 && bytes[root_index - 1] == b'.'; + if !follows_member_access + && (&expression[start..index] == "context" + || matches!(bytes.get(member_index), Some(b'.' | b'['))) { roots.insert(expression[start..index].to_string()); } @@ -532,6 +538,20 @@ mod enabled { .expect("quoted member-like text is not authority"); } + #[test] + fn expression_authority_accepts_nested_and_method_member_chains() { + for expression in [ + "source.person.name == 'Ada'", + "source.name.startsWith('A')", + "source['person'].name == 'Ada'", + "source . context == 'profile-field'", + ] { + validate_release_expression(expression).unwrap_or_else(|_| { + panic!("source-only expression must compile: {expression}") + }); + } + } + #[test] fn expression_authority_rejects_unterminated_strings() { let err = validate_release_expression("source.given_name == 'unterminated") diff --git a/crates/registryctl/src/project_authoring/compiler/notary.rs b/crates/registryctl/src/project_authoring/compiler/notary.rs index f661a8f59..c3101f8de 100644 --- a/crates/registryctl/src/project_authoring/compiler/notary.rs +++ b/crates/registryctl/src/project_authoring/compiler/notary.rs @@ -893,7 +893,18 @@ fn cel_member_roots(expression: &str) -> Result> { { index += 1; } - if bytes.get(index) == Some(&b'.') { + let mut member_index = index; + while bytes.get(member_index).is_some_and(u8::is_ascii_whitespace) { + member_index += 1; + } + let mut root_index = start; + while root_index > 0 && bytes[root_index - 1].is_ascii_whitespace() { + root_index -= 1; + } + let follows_member_access = root_index > 0 && bytes[root_index - 1] == b'.'; + if !follows_member_access + && matches!(bytes.get(member_index), Some(b'.' | b'[')) + { roots.insert(expression[start..index].to_string()); } continue; diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 8eee1814c..ca285b102 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -2104,6 +2104,14 @@ outputs: cel_member_roots("'decoy.exists' == 'x' && person.exists").expect("CEL roots parse"), BTreeSet::from(["person".to_string()]) ); + assert_eq!( + cel_member_roots( + "person.details.name == 'Ada' && person.name.startsWith('A') \ + && person['details'].active" + ) + .expect("nested CEL roots parse"), + BTreeSet::from(["person".to_string()]) + ); assert!(cel_member_roots("person.exists && 'unterminated").is_err()); } From b663f1dec32f8e78bcedc6d24e334b1be81166a1 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sat, 25 Jul 2026 23:30:15 +0700 Subject: [PATCH 4/9] fix(relay): complete promotion review contracts Signed-off-by: Jeremi Joslin --- crates/registry-relay/Dockerfile | 2 + crates/registry-relay/README.md | 7 ++- crates/registry-relay/docs/ops.md | 6 +++ .../openapi/registry-relay.openapi.json | 20 ++++++-- .../scripts/check_docker_build_contract.py | 46 +++++++++++++++++++ .../scripts/validate-feature-profile.sh | 45 ++++++++++++++++++ crates/registry-relay/src/api/openapi.rs | 21 +++++++-- 7 files changed, 139 insertions(+), 8 deletions(-) create mode 100755 crates/registry-relay/scripts/validate-feature-profile.sh diff --git a/crates/registry-relay/Dockerfile b/crates/registry-relay/Dockerfile index 5dfe7c35e..afcdef46f 100644 --- a/crates/registry-relay/Dockerfile +++ b/crates/registry-relay/Dockerfile @@ -16,11 +16,13 @@ COPY --from=crosswalk /Cargo.toml /Cargo.lock /workspace/crosswalk/ COPY --from=crosswalk /crates /workspace/crosswalk/crates COPY benches ./benches COPY resources ./resources +COPY scripts/validate-feature-profile.sh ./scripts/validate-feature-profile.sh COPY src ./src RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/workspace/registry_relay/target \ find src benches resources -type f -exec touch {} + && \ + sh scripts/validate-feature-profile.sh "$REGISTRY_RELAY_FEATURES" && \ if [ -n "$REGISTRY_RELAY_FEATURES" ]; then \ cargo build --release --locked --no-default-features --features "$REGISTRY_RELAY_FEATURES"; \ else \ diff --git a/crates/registry-relay/README.md b/crates/registry-relay/README.md index 1c9d9a8c3..a7f13f82d 100644 --- a/crates/registry-relay/README.md +++ b/crates/registry-relay/README.md @@ -108,8 +108,11 @@ scripts/build-image.sh registry-relay:local The production image is distroless, non-root, and built with the canonical `attribute-release,crosswalk-runtime` feature set. A custom -`REGISTRY_RELAY_FEATURES` value replaces that exact set. Build steps, -sibling-checkout requirements, and promotion gates are in +`REGISTRY_RELAY_FEATURES` value replaces that exact set. Custom profiles must +list transitive product features explicitly, so `attribute-release` and +`standards-cel-mapping` each require `crosswalk-runtime`; incomplete or +duplicate profiles fail the image build. Build steps, sibling-checkout +requirements, and promotion gates are in [docs/ops.md](docs/ops.md#build-and-release); image publication, tagging, and signing policy are in [docs/security-assurance.md](docs/security-assurance.md). Release images publish to `ghcr.io/registrystack/registry-relay` from stable diff --git a/crates/registry-relay/docs/ops.md b/crates/registry-relay/docs/ops.md index 0946526ed..6ec46936b 100644 --- a/crates/registry-relay/docs/ops.md +++ b/crates/registry-relay/docs/ops.md @@ -207,6 +207,12 @@ REGISTRY_RELAY_FEATURES=attribute-release,crosswalk-runtime,spdci-api-standards, scripts/build-image.sh registry-relay:-standards ``` +Custom profiles use unique comma-separated Cargo feature names and must name +transitive product features explicitly. Both `attribute-release` and +`standards-cel-mapping` require `crosswalk-runtime`; the image build rejects an +incomplete profile so its feature label stays consistent with +`/admin/v1/capabilities`. + If release notes claim SP DCI, standards CEL mapping, or OGC EDR support, record the standards-enabled image tag or digest in the release evidence. diff --git a/crates/registry-relay/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index 6c9a1ff27..df5e61c47 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -1286,8 +1286,12 @@ "type": "string" }, "value": { - "description": "Subject identifier value. Never logged or echoed in responses.", - "type": "string" + "description": "Non-blank scalar subject identifier value. String, number, and boolean values are matched without coercion. Never logged or echoed in responses.", + "type": [ + "string", + "number", + "boolean" + ] } }, "required": [ @@ -5491,7 +5495,7 @@ } } }, - "description": "Invalid request: missing Data-Purpose header, malformed body, unknown id_type, empty claims list, or unsupported media type." + "description": "Invalid request: missing Data-Purpose header, malformed body, unknown id_type, or empty claims list." }, "401": { "content": { @@ -5523,6 +5527,16 @@ }, "description": "Profile not found or not visible to the authenticated principal. Does not confirm whether the profile ever existed." }, + "415": { + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + }, + "description": "Unsupported media type: request body must be application/json." + }, "503": { "content": { "application/problem+json": { diff --git a/crates/registry-relay/scripts/check_docker_build_contract.py b/crates/registry-relay/scripts/check_docker_build_contract.py index a0a822803..e4fb133bb 100755 --- a/crates/registry-relay/scripts/check_docker_build_contract.py +++ b/crates/registry-relay/scripts/check_docker_build_contract.py @@ -3,6 +3,7 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path @@ -71,12 +72,43 @@ def forbid_documented_unpinned_build_context(path: Path) -> list[str]: ] +def check_feature_profile_script(path: Path) -> list[str]: + failures: list[str] = [] + cases = [ + ("", True), + ("attribute-release,crosswalk-runtime", True), + ("standards-cel-mapping,crosswalk-runtime", True), + ("ogcapi-edr", True), + ("attribute-release", False), + ("standards-cel-mapping", False), + ("attribute-release,crosswalk-runtime,attribute-release", False), + ("attribute-release crosswalk-runtime", False), + ] + for profile, expected_success in cases: + result = subprocess.run( + ["sh", str(path), profile], + check=False, + capture_output=True, + text=True, + ) + succeeded = result.returncode == 0 + if succeeded != expected_success: + expected = "accept" if expected_success else "reject" + failures.append( + f"{path.relative_to(ROOT)}: expected to {expected} feature profile " + f"{profile!r}: {result.stderr.strip()}" + ) + return failures + + def main() -> int: dockerfile = ROOT / "Dockerfile" build_script = ROOT / "scripts" / "build-image.sh" + feature_profile_script = ROOT / "scripts" / "validate-feature-profile.sh" docs = [ROOT / "README.md", ROOT / "docs" / "ops.md"] failures: list[str] = [] + failures.extend(check_feature_profile_script(feature_profile_script)) failures.extend( require( dockerfile, @@ -91,6 +123,20 @@ def main() -> int: "feature-enabled cargo build path", ) ) + failures.extend( + require( + dockerfile, + "COPY scripts/validate-feature-profile.sh ./scripts/validate-feature-profile.sh", + "feature profile validator copy", + ) + ) + failures.extend( + require( + dockerfile, + 'sh scripts/validate-feature-profile.sh "$REGISTRY_RELAY_FEATURES"', + "feature dependency closure validation", + ) + ) failures.extend( require( dockerfile, diff --git a/crates/registry-relay/scripts/validate-feature-profile.sh b/crates/registry-relay/scripts/validate-feature-profile.sh new file mode 100755 index 000000000..6e6382279 --- /dev/null +++ b/crates/registry-relay/scripts/validate-feature-profile.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# SPDX-License-Identifier: Apache-2.0 +set -eu + +features="${1-}" + +fail() { + printf 'invalid Registry Relay feature profile: %s\n' "$1" >&2 + exit 1 +} + +case "$features" in + *[!a-z0-9,-]*) + fail "use comma-separated Cargo feature names" + ;; + ,*|*,|*,,*) + fail "feature list must not contain empty entries" + ;; +esac + +has_feature() { + case ",$features," in + *,"$1",*) return 0 ;; + *) return 1 ;; + esac +} + +seen="," +if [ -n "$features" ]; then + previous_ifs=$IFS + IFS=, + for feature in $features; do + case "$seen" in + *,"$feature",*) fail "duplicate feature: $feature" ;; + esac + seen="${seen}${feature}," + done + IFS=$previous_ifs +fi + +for feature in attribute-release standards-cel-mapping; do + if has_feature "$feature" && ! has_feature crosswalk-runtime; then + fail "$feature requires crosswalk-runtime" + fi +done diff --git a/crates/registry-relay/src/api/openapi.rs b/crates/registry-relay/src/api/openapi.rs index 6fa7c0af6..906aa2e43 100644 --- a/crates/registry-relay/src/api/openapi.rs +++ b/crates/registry-relay/src/api/openapi.rs @@ -4152,7 +4152,7 @@ fn insert_attribute_release_paths(paths: &mut Map) { }, "400": problem_response( "Invalid request: missing Data-Purpose header, malformed body, unknown \ - id_type, empty claims list, or unsupported media type." + id_type, or empty claims list." ), "401": problem_response("Missing or invalid bearer credential."), "403": problem_response( @@ -4164,6 +4164,9 @@ fn insert_attribute_release_paths(paths: &mut Map) { "Profile not found or not visible to the authenticated principal. \ Does not confirm whether the profile ever existed." ), + "415": problem_response( + "Unsupported media type: request body must be application/json." + ), "503": problem_response("Source unavailable."), "default": problem_response("Problem Details error response."), } @@ -5334,8 +5337,10 @@ fn attribute_release_resolve_request_schema() -> Value { "description": "Subject identifier type (e.g. `national_id`, `passport`)." }, "value": { - "type": "string", - "description": "Subject identifier value. Never logged or echoed in responses." + "type": ["string", "number", "boolean"], + "description": "Non-blank scalar subject identifier value. String, number, \ + and boolean values are matched without coercion. Never \ + logged or echoed in responses." } }, "additionalProperties": false @@ -6467,6 +6472,10 @@ mod tests { resolve_op["responses"]["404"].is_object(), "404 must be present" ); + assert!( + resolve_op["responses"]["415"].is_object(), + "415 must be present" + ); assert!( resolve_op["responses"]["503"].is_object(), "503 must be present" @@ -6492,6 +6501,12 @@ mod tests { schemas.contains_key("AttributeReleaseResolveResponse"), "AttributeReleaseResolveResponse schema must be present" ); + assert_eq!( + schemas["AttributeReleaseResolveRequest"]["properties"]["subject"]["properties"] + ["value"]["type"], + json!(["string", "number", "boolean"]), + "subject value must document every runtime-supported scalar type" + ); // Required fields on AttributeReleaseProfile schema let profile_required = &schemas["AttributeReleaseProfile"]["required"]; From 3740ebea4468821f5e65e8be19a2b7a277a78ef8 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 11:22:47 +0700 Subject: [PATCH 5/9] fix(relay): address latest promotion review Signed-off-by: Jeremi Joslin --- .github/workflows/release-candidate.yml | 3 +- crates/registry-relay/README.md | 5 +- .../canonical-release-features.txt | 1 + crates/registry-relay/docs/ops.md | 5 +- .../registry-relay/docs/security-assurance.md | 18 ++--- .../scripts/check-openapi-contract.sh | 5 +- .../scripts/check_docker_build_contract.py | 23 +++++- .../scripts/check_security_assurance.py | 79 ++++++++++++------- crates/registry-relay/src/config/validate.rs | 4 +- .../registry-relay/tests/config_entities.rs | 13 +++ .../tests/security_assurance_check_test.py | 30 +++++-- .../src/project_authoring/output.rs | 8 ++ .../src/project_authoring/project.rs | 18 ++++- .../src/project_authoring/tests.rs | 73 +++++++++++++++++ release/scripts/build-release-binaries.sh | 7 +- release/scripts/build-release-image.sh | 6 +- .../scripts/smoke-release-image-oci-labels.sh | 5 +- .../test_check_release_image_oci_labels.py | 7 +- release/scripts/test_registry_release.py | 24 +++++- 19 files changed, 274 insertions(+), 60 deletions(-) create mode 100644 crates/registry-relay/canonical-release-features.txt diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 553037c45..b5838b5d2 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -858,8 +858,9 @@ jobs: digest_ref="$(cat "inputs/build-a/dist/images/${name}.digest")" expected_label_args=() if [[ "${name}" == "registry-relay" ]]; then + relay_features="$( "$GENERATED" +sh scripts/validate-feature-profile.sh "$RELEASE_FEATURES" +cargo run -q --no-default-features --features "$RELEASE_FEATURES" \ + --bin registry-relay -- openapi --config "$REFERENCE_CONFIG" > "$GENERATED" python3 - "$SPEC_PATH" "$GENERATED" <<'PY' import json diff --git a/crates/registry-relay/scripts/check_docker_build_contract.py b/crates/registry-relay/scripts/check_docker_build_contract.py index e4fb133bb..c2f7068f0 100755 --- a/crates/registry-relay/scripts/check_docker_build_contract.py +++ b/crates/registry-relay/scripts/check_docker_build_contract.py @@ -101,18 +101,39 @@ def check_feature_profile_script(path: Path) -> list[str]: return failures +def check_feature_profile(path: Path, profile: str) -> list[str]: + result = subprocess.run( + ["sh", str(path), profile], + check=False, + capture_output=True, + text=True, + ) + if result.returncode == 0: + return [] + return [ + f"{path.relative_to(ROOT)}: canonical release feature profile " + f"{profile!r} is invalid: {result.stderr.strip()}" + ] + + def main() -> int: dockerfile = ROOT / "Dockerfile" build_script = ROOT / "scripts" / "build-image.sh" feature_profile_script = ROOT / "scripts" / "validate-feature-profile.sh" + canonical_feature_profile = ( + ROOT / "canonical-release-features.txt" + ).read_text(encoding="utf-8").strip() docs = [ROOT / "README.md", ROOT / "docs" / "ops.md"] failures: list[str] = [] failures.extend(check_feature_profile_script(feature_profile_script)) + failures.extend( + check_feature_profile(feature_profile_script, canonical_feature_profile) + ) failures.extend( require( dockerfile, - 'ARG REGISTRY_RELAY_FEATURES="attribute-release,crosswalk-runtime"', + f'ARG REGISTRY_RELAY_FEATURES="{canonical_feature_profile}"', "canonical feature build arg", ) ) diff --git a/crates/registry-relay/scripts/check_security_assurance.py b/crates/registry-relay/scripts/check_security_assurance.py index 66d212788..7d412acf8 100755 --- a/crates/registry-relay/scripts/check_security_assurance.py +++ b/crates/registry-relay/scripts/check_security_assurance.py @@ -49,6 +49,7 @@ ) CONST_STR_RE = re.compile(r'const\s+([A-Z][A-Z0-9_]*)\s*:\s*&str\s*=\s*"([^"]+)"\s*;') OPENAPI_HTTP_METHODS = {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"} +RELEASE_FEATURE_RE = re.compile(r"^[a-z0-9-]+$") # Keep this empty unless a static OpenAPI operation is intentionally broader # than the security exposure manifest. Each entry must explain why the drift is @@ -85,10 +86,11 @@ def load_json(path: Path) -> object: fail(f"{path.relative_to(ROOT)} is not valid JSON: {exc}") -def load_default_features() -> set[str]: - path = ROOT / "Cargo.toml" +def load_release_features() -> set[str]: + cargo_path = ROOT / "Cargo.toml" + profile_path = ROOT / "canonical-release-features.txt" try: - document = tomllib.loads(path.read_text(encoding="utf-8")) + document = tomllib.loads(cargo_path.read_text(encoding="utf-8")) except FileNotFoundError: fail("missing required file: Cargo.toml") except tomllib.TOMLDecodeError as exc: @@ -97,25 +99,44 @@ def load_default_features() -> set[str]: features = document.get("features") if not isinstance(features, dict): fail("Cargo.toml must contain a [features] table") - defaults = features.get("default") - if not isinstance(defaults, list) or not all( - isinstance(feature, str) for feature in defaults + + try: + profile = profile_path.read_text(encoding="utf-8").strip() + except FileNotFoundError: + fail("missing required file: canonical-release-features.txt") + release_features = profile.split(",") if profile else [] + if ( + not release_features + or any(not RELEASE_FEATURE_RE.fullmatch(feature) for feature in release_features) + or len(set(release_features)) != len(release_features) ): - fail("Cargo.toml features.default must be a list of feature names") + fail( + "canonical-release-features.txt must contain unique comma-separated " + "Cargo feature names" + ) - enabled: set[str] = set() - pending = list(defaults) - while pending: - feature = pending.pop() - if feature in enabled: - continue - members = features.get(feature) + enabled = set(release_features) + unknown = sorted(enabled - features.keys()) + if unknown: + fail( + "canonical-release-features.txt names unknown Cargo features: " + f"{unknown}" + ) + missing_dependencies: set[str] = set() + for feature in enabled: + members = features[feature] if not isinstance(members, list) or not all( isinstance(member, str) for member in members ): - fail(f"Cargo.toml default feature {feature} must name a feature list") - enabled.add(feature) - pending.extend(member for member in members if member in features) + fail(f"Cargo.toml feature {feature} must name a feature list") + missing_dependencies.update( + member for member in members if member in features and member not in enabled + ) + if missing_dependencies: + fail( + "canonical-release-features.txt must explicitly include transitive " + f"product features: {sorted(missing_dependencies)}" + ) return enabled @@ -161,7 +182,7 @@ def validate_manifest() -> None: manifest = load_json(SECURITY_DIR / "exposure-manifest.json") inventory = load_json(SECURITY_DIR / "route-inventory.json") allowlist = load_allowlist(SECURITY_DIR / "auth-none-allowlist.yml") - default_features = load_default_features() + release_features = load_release_features() if not isinstance(manifest, dict) or manifest.get("service") != "registry-relay": fail("exposure-manifest.json must describe service registry-relay") @@ -186,7 +207,7 @@ def validate_manifest() -> None: check_value(entry, "stability", STABILITY) check_value(entry, "data_classification", DATA) check_value(entry, "source", SOURCES) - validate_stability(entry, default_features) + validate_stability(entry, release_features) if entry["method"] not in {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}: fail(f"{entry['path']} has invalid method {entry['method']}") if not isinstance(entry["scopes"], list): @@ -236,13 +257,13 @@ def validate_manifest() -> None: def validate_stability( - entry: dict, default_features: set[str] | None = None + entry: dict, release_features: set[str] | None = None ) -> None: - default_features = default_features or set() + release_features = release_features or set() feature = entry["feature"] if ( feature is not None - and feature not in default_features + and feature not in release_features and entry["stability"] != "experimental" ): fail( @@ -458,7 +479,7 @@ def check_openapi_strategy() -> None: def check_openapi_manifest_coverage(path: Path) -> None: manifest = load_json(SECURITY_DIR / "exposure-manifest.json") document = load_json(path) - default_features = load_default_features() + release_features = load_release_features() if not isinstance(manifest, dict) or not isinstance(document, dict): fail("OpenAPI coverage inputs must be JSON objects") endpoints = manifest.get("endpoints") @@ -478,8 +499,8 @@ def check_openapi_manifest_coverage(path: Path) -> None: fail("endpoint entries must be objects") op_key = (openapi_path_shape(str(entry.get("path"))), str(entry.get("method"))) manifest_ops.setdefault(op_key, []).append(entry) - if entry.get("openapi") is True and is_default_openapi_entry( - entry, default_features + if entry.get("openapi") is True and is_release_openapi_entry( + entry, release_features ): manifest_openapi_ops.add(op_key) @@ -487,7 +508,7 @@ def check_openapi_manifest_coverage(path: Path) -> None: (entry["method"], entry["path"]) for entry in endpoints if entry.get("openapi") - and is_default_openapi_entry(entry, default_features) + and is_release_openapi_entry(entry, release_features) and (openapi_path_shape(entry["path"]), entry["method"]) not in openapi_op_keys ) if missing: @@ -526,11 +547,11 @@ def check_openapi_manifest_coverage(path: Path) -> None: ) -def is_default_openapi_entry( - entry: dict, default_features: set[str] | None = None +def is_release_openapi_entry( + entry: dict, release_features: set[str] | None = None ) -> bool: feature = entry.get("feature") - return feature is None or feature in (default_features or set()) + return feature is None or feature in (release_features or set()) def openapi_path_shape(path: str) -> str: diff --git a/crates/registry-relay/src/config/validate.rs b/crates/registry-relay/src/config/validate.rs index 01da539f2..c0ef9eaba 100644 --- a/crates/registry-relay/src/config/validate.rs +++ b/crates/registry-relay/src/config/validate.rs @@ -3787,9 +3787,9 @@ fn validate_entity_release_profile( "attribute_release_profiles title and description must be bounded, trimmed text", ); } - if !is_bounded_release_token(&profile.purpose, 256) { + if !profile.purpose.is_ascii() || !is_bounded_release_token(&profile.purpose, 256) { return release_error( - "attribute_release_profiles purpose must be one bounded token of at most 256 bytes", + "attribute_release_profiles purpose must be one visible-ASCII bounded token of at most 256 bytes", ); } let expected_release_scope = format!("{}:identity_release", dataset.id); diff --git a/crates/registry-relay/tests/config_entities.rs b/crates/registry-relay/tests/config_entities.rs index f157a8365..d8eb3cf08 100644 --- a/crates/registry-relay/tests/config_entities.rs +++ b/crates/registry-relay/tests/config_entities.rs @@ -668,6 +668,19 @@ fn release_profile_requires_bounded_purpose_and_subject_id_type() { ); } +#[cfg(feature = "attribute-release")] +#[test] +fn release_profile_purpose_must_be_visible_ascii() { + let non_ascii_purpose = valid_release_profile().replace( + "purpose: identity_verification", + "purpose: identity_vérification", + ); + assert_eq!( + load_release_dataset(&non_ascii_purpose).expect_err("non-ASCII purpose rejected"), + "config.validation_error" + ); +} + #[cfg(feature = "attribute-release")] #[test] fn release_profile_rejects_principal_bound_required_filter_entities() { diff --git a/crates/registry-relay/tests/security_assurance_check_test.py b/crates/registry-relay/tests/security_assurance_check_test.py index ffd59a053..8d4b465c5 100644 --- a/crates/registry-relay/tests/security_assurance_check_test.py +++ b/crates/registry-relay/tests/security_assurance_check_test.py @@ -68,8 +68,9 @@ def write_contracts(self, manifest_entry): "endpoints": [manifest_entry], })) - def write_features(self, default=None): + def write_features(self, default=None, release=None): default = default or [] + release = release or ["attribute-release", "crosswalk-runtime"] rendered_default = json.dumps(default) (self.root / "Cargo.toml").write_text( "[features]\n" @@ -78,6 +79,9 @@ def write_features(self, default=None): "crosswalk-runtime = []\n" "ogcapi-features = []\n" ) + (self.root / "canonical-release-features.txt").write_text( + ",".join(release) + "\n" + ) def entry(self, **overrides): base = { @@ -116,19 +120,33 @@ def test_optional_feature_gated_endpoint_must_remain_experimental(self): ) self.module.validate_manifest() - def test_default_feature_gated_endpoint_may_be_stable(self): - self.write_features(["attribute-release"]) + def test_release_feature_gated_endpoint_may_be_stable(self): + self.write_features(default=["ogcapi-features"]) self.write_contracts( self.entry(feature="attribute-release", stability="stable") ) self.module.validate_manifest() - def test_default_feature_expansion_includes_local_feature_dependencies(self): - self.write_features(["attribute-release"]) + def test_developer_default_does_not_make_optional_route_stable(self): + self.write_features(default=["ogcapi-features"]) + self.write_contracts( + self.entry(feature="ogcapi-features", stability="stable") + ) + with self.assertRaises(SystemExit): + self.module.validate_manifest() + + def test_release_profile_requires_explicit_local_feature_dependencies(self): + self.write_features(release=["attribute-release"]) + with self.assertRaises(SystemExit): + self.module.load_release_features() + + self.write_features( + release=["attribute-release", "crosswalk-runtime"] + ) self.assertEqual( - self.module.load_default_features(), + self.module.load_release_features(), {"attribute-release", "crosswalk-runtime"}, ) diff --git a/crates/registryctl/src/project_authoring/output.rs b/crates/registryctl/src/project_authoring/output.rs index bf040ab4e..9cdaf4265 100644 --- a/crates/registryctl/src/project_authoring/output.rs +++ b/crates/registryctl/src/project_authoring/output.rs @@ -1318,6 +1318,14 @@ fn validate_token(value: &str, field: &str, max_bytes: usize) -> Result<()> { Ok(()) } +fn validate_header_token(value: &str, field: &str, max_bytes: usize) -> Result<()> { + validate_token(value, field, max_bytes)?; + if !value.is_ascii() { + bail!("{field} must use visible ASCII"); + } + Ok(()) +} + fn validate_scopes(scopes: &[String]) -> Result<()> { if scopes.is_empty() || scopes.len() > 16 { bail!("caller scopes must contain between one and 16 entries"); diff --git a/crates/registryctl/src/project_authoring/project.rs b/crates/registryctl/src/project_authoring/project.rs index fc5cc2d8f..40622b0d1 100644 --- a/crates/registryctl/src/project_authoring/project.rs +++ b/crates/registryctl/src/project_authoring/project.rs @@ -1448,6 +1448,18 @@ fn validate_record_attribute_release_profiles( entity: &EntityDefinition, fields: &BTreeSet<&str>, ) -> Result<()> { + if !api.attribute_release_profiles.is_empty() + && !api.required_principal_filters.is_empty() + { + bail!( + "attribute release profiles cannot use required principal filters because the caller-supplied subject cannot satisfy a principal-bound filter" + ); + } + if !api.attribute_release_profiles.is_empty() && api.pagination.max_limit < 2 { + bail!( + "attribute release profiles require records pagination max_limit of at least 2 to detect ambiguous subjects" + ); + } let projected = api .projection .iter() @@ -1466,7 +1478,11 @@ fn validate_record_attribute_release_profiles( if let Some(description) = &profile.description { validate_authored_text(description, "attribute release profile description")?; } - validate_token(&profile.purpose, "attribute release profile purpose", 256)?; + validate_header_token( + &profile.purpose, + "attribute release profile purpose", + 256, + )?; if !api.purposes.contains(&profile.purpose) { bail!("attribute release profile purpose must be a records API permitted purpose"); } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index ca285b102..c03d06c9a 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -493,6 +493,79 @@ outputs: ); } + #[test] + fn attribute_release_purpose_must_be_header_safe_during_authoring() { + let mut loaded = load_registry_project( + &project_golden("nia-attribute-release"), + Some("local"), + ) + .expect("NIA release project loads"); + let api = loaded + .project + .services + .get_mut("nia-population-records") + .and_then(|service| service.api.as_mut()) + .expect("records API exists"); + let purpose = "identité_verification".to_string(); + api.purposes[0] = purpose.clone(); + api.attribute_release_profiles + .get_mut("solmara-nia-userinfo") + .expect("release profile exists") + .purpose = purpose; + + let error = validate_project_entity_links( + &loaded.project, + &loaded.integrations, + &loaded.entities, + ) + .expect_err("header-bound release purpose must use visible ASCII"); + assert!( + error.to_string().contains("purpose must use visible ASCII"), + "unexpected diagnostic: {error:#}" + ); + } + + #[test] + fn attribute_release_prerequisites_fail_during_authoring() { + for case in ["required principal filters", "pagination max_limit"] { + let mut loaded = load_registry_project( + &project_golden("nia-attribute-release"), + Some("local"), + ) + .expect("NIA release project loads"); + let api = loaded + .project + .services + .get_mut("nia-population-records") + .and_then(|service| service.api.as_mut()) + .expect("records API exists"); + let expected = match case { + "required principal filters" => { + api.required_principal_filters + .push("legacy_nid".to_string()); + "attribute release profiles cannot use required principal filters" + } + "pagination max_limit" => { + api.pagination.default_limit = 1; + api.pagination.max_limit = 1; + "attribute release profiles require records pagination max_limit of at least 2" + } + _ => unreachable!("unknown prerequisite case"), + }; + + let error = validate_project_entity_links( + &loaded.project, + &loaded.integrations, + &loaded.entities, + ) + .expect_err("invalid release prerequisite must fail during authoring"); + assert!( + error.to_string().contains(expected), + "unexpected {case} diagnostic: {error:#}" + ); + } + } + #[test] fn interval_materialization_refresh_uses_an_operational_bound() { let project = project_golden("nia-attribute-release"); diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index 0426d99e9..a2fd2a7b0 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -19,6 +19,10 @@ fi release_builder_image="${default_builder_image}" release_cargo_home="${RELEASE_CARGO_HOME:-${repo_root}/.cargo-home}" release_target_dir="${RELEASE_TARGET_DIR:-${repo_root}/target}" +relay_feature_profile="${repo_root}/crates/registry-relay/canonical-release-features.txt" +relay_release_features="$(<"${relay_feature_profile}")" +sh "${repo_root}/crates/registry-relay/scripts/validate-feature-profile.sh" \ + "${relay_release_features}" if [[ "${release_cargo_home}" != /* ]]; then release_cargo_home="${repo_root}/${release_cargo_home}" @@ -49,6 +53,7 @@ docker run --rm \ --env CARGO_TERM_COLOR="${CARGO_TERM_COLOR:-always}" \ --env HOME=/workspace \ --env RELEASE_TAG="${tag}" \ + --env RELEASE_RELAY_FEATURES="${relay_release_features}" \ --env RELEASE_RUSTFLAGS="${release_rustflags}" \ "${release_builder_image}" \ bash -c 'set -euo pipefail @@ -66,7 +71,7 @@ docker run --rm \ cargo build --release --locked \ -p registry-relay \ --no-default-features \ - --features registry-relay/attribute-release,registry-relay/crosswalk-runtime + --features "${RELEASE_RELAY_FEATURES}" python3 release/scripts/check-release-relay-features.py target/release/registry-relay cp target/release/registry-relay "dist/bin/registry-relay-${RELEASE_TAG}-linux-amd64" cp target/release/registry-relay-rhai-worker "dist/bin/registry-relay-rhai-worker-${RELEASE_TAG}-linux-amd64" diff --git a/release/scripts/build-release-image.sh b/release/scripts/build-release-image.sh index d40f2ee93..96ef60727 100755 --- a/release/scripts/build-release-image.sh +++ b/release/scripts/build-release-image.sh @@ -35,8 +35,12 @@ esac product_label_args=() if [[ "${name}" == "registry-relay" ]]; then + relay_feature_profile="${repo_root}/crates/registry-relay/canonical-release-features.txt" + relay_release_features="$(<"${relay_feature_profile}")" + sh "${repo_root}/crates/registry-relay/scripts/validate-feature-profile.sh" \ + "${relay_release_features}" product_label_args+=( - --label "org.registrystack.registry-relay.features=attribute-release,crosswalk-runtime" + --label "org.registrystack.registry-relay.features=${relay_release_features}" ) fi diff --git a/release/scripts/smoke-release-image-oci-labels.sh b/release/scripts/smoke-release-image-oci-labels.sh index 6e40d1697..9e03c8151 100755 --- a/release/scripts/smoke-release-image-oci-labels.sh +++ b/release/scripts/smoke-release-image-oci-labels.sh @@ -14,7 +14,10 @@ revision_label="0123456789abcdef0123456789abcdef01234567" wrong_revision_label="89abcdef0123456789abcdef0123456789abcdef" version_label="v0.0.0-oci-label-smoke" source_date_epoch=0 -relay_features_label="org.registrystack.registry-relay.features=attribute-release,crosswalk-runtime" +relay_release_features="$(<"${repo_root}/crates/registry-relay/canonical-release-features.txt")" +sh "${repo_root}/crates/registry-relay/scripts/validate-feature-profile.sh" \ + "${relay_release_features}" +relay_features_label="org.registrystack.registry-relay.features=${relay_release_features}" tmp_root="$(mktemp -d "${TMPDIR:-/tmp}/registry-stack-oci-labels.XXXXXX")" smoke_builder="registry-stack-release-smoke-$$-${RANDOM}" diff --git a/release/scripts/test_check_release_image_oci_labels.py b/release/scripts/test_check_release_image_oci_labels.py index c0e78daf7..33019be84 100644 --- a/release/scripts/test_check_release_image_oci_labels.py +++ b/release/scripts/test_check_release_image_oci_labels.py @@ -24,7 +24,12 @@ REVISION = "b" * 40 VERSION = "v0.12.0" RELAY_FEATURE_LABEL = "org.registrystack.registry-relay.features" -RELAY_FEATURES = "attribute-release,crosswalk-runtime" +RELAY_FEATURES = ( + Path(__file__).resolve().parents[2] + / "crates" + / "registry-relay" + / "canonical-release-features.txt" +).read_text(encoding="utf-8").strip() BUILDKIT_IMAGE = ( "moby/buildkit:v0.31.2@sha256:" "2f5adac4ecd194d9f8c10b7b5d7bceb5186853db1b26e5abd3a657af0b7e26ec" diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index b1898d9b0..83c22b5b0 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -271,8 +271,11 @@ def test_release_images_publish_and_executably_verify_oci_labels(self) -> None: '--version "${{ needs.validate.outputs.version }}"', verification_job ) self.assertIn( - '--expected-label "org.registrystack.registry-relay.features=' - 'attribute-release,crosswalk-runtime"', + 'relay_features="$( None: self.assertNotIn("-p registry-relay", registryctl_commands[0]) self.assertNotIn("-p registryctl", relay_commands[0]) self.assertIn("--no-default-features", relay_commands[0]) + self.assertIn( + "crates/registry-relay/canonical-release-features.txt", + binary_recipe, + ) + self.assertIn( + '--env RELEASE_RELAY_FEATURES="${relay_release_features}"', + binary_recipe, + ) + self.assertIn('--features "${RELEASE_RELAY_FEATURES}"', relay_commands[0]) feature_check = ( "python3 release/scripts/check-release-relay-features.py " "target/release/registry-relay" @@ -394,6 +406,14 @@ def test_release_build_wrappers_are_executable_and_canonical(self) -> None: self.assertEqual(1, images_job.count("release/scripts/build-release-image.sh")) self.assertNotIn("docker buildx build", images_job) self.assertIn("registry-notary|registry-relay", image_recipe) + self.assertIn( + "crates/registry-relay/canonical-release-features.txt", + image_recipe, + ) + self.assertIn( + "org.registrystack.registry-relay.features=${relay_release_features}", + image_recipe, + ) self.assertIn("SOURCE_DATE_EPOCH=${source_date_epoch}", image_recipe) self.assertIn("source_date_epoch=0", image_recipe) self.assertNotIn("${SOURCE_DATE_EPOCH", image_recipe) From 277595685daf77bf209fb7e33c68276fe15d978d Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 11:26:32 +0700 Subject: [PATCH 6/9] test(docs): follow canonical Relay release profile Signed-off-by: Jeremi Joslin --- .../scripts/relay-release-contract.test.mjs | 47 +++++++++++++------ 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/docs/site/scripts/relay-release-contract.test.mjs b/docs/site/scripts/relay-release-contract.test.mjs index 8ae4285c5..635a529ae 100644 --- a/docs/site/scripts/relay-release-contract.test.mjs +++ b/docs/site/scripts/relay-release-contract.test.mjs @@ -131,6 +131,14 @@ test('canonical Relay release, local image, and OpenAPI use the same feature set new Set(['attribute-release', 'crosswalk-runtime']), 'the approved 1.0 Relay feature list must be exact', ); + const canonicalProfile = ( + await readRepo('crates/registry-relay/canonical-release-features.txt') + ).trim(); + assert.deepEqual( + new Set(canonicalProfile.split(',')), + canonicalFeatures, + 'the canonical release profile must match the approved 1.0 roster', + ); const cargoToml = await readRepo('crates/registry-relay/Cargo.toml'); const cargoFeatureSection = cargoToml.match(/\[features\]\n([\s\S]*?)\n\[/)?.[1]; @@ -155,9 +163,10 @@ test('canonical Relay release, local image, and OpenAPI use the same feature set ); const dockerfile = await readRepo('crates/registry-relay/Dockerfile'); - assert.match( - dockerfile, - /^ARG REGISTRY_RELAY_FEATURES="attribute-release,crosswalk-runtime"$/m, + const dockerProfile = dockerfile.match(/^ARG REGISTRY_RELAY_FEATURES="([^"]+)"$/m)?.[1]; + assert.equal( + dockerProfile, + canonicalProfile, 'the local production image must default to the canonical feature set', ); @@ -166,19 +175,29 @@ test('canonical Relay release, local image, and OpenAPI use the same feature set const releaseRecipe = isAbsolute(releaseRecipePath) ? await readFile(releaseRecipePath, 'utf8') : await readRepo(releaseRecipePath); - const relayBuild = releaseRecipe.match( - /-p registry-relay \\\n\s+--no-default-features \\\n\s+--features ([^\s'"]+)/, + assert.match( + releaseRecipe, + /relay_feature_profile=.*crates\/registry-relay\/canonical-release-features\.txt/, + 'the release recipe must read the canonical Relay feature profile', ); - assert.ok(relayBuild, 'the release recipe must select an exact Relay feature set'); - const workflowRelayFeatures = new Set( - relayBuild[1] - .split(',') - .map((feature) => feature.replace(/^registry-relay\//, '')), + assert.match( + releaseRecipe, + /-p registry-relay \\\n\s+--no-default-features \\\n\s+--features "\$\{RELEASE_RELAY_FEATURES\}"/, + 'the release recipe must select the canonical Relay feature profile exactly', ); - assert.deepEqual( - workflowRelayFeatures, - canonicalFeatures, - 'the release workflow Relay features must match the canonical 1.0 roster', + + const openapiContract = await readRepo( + 'crates/registry-relay/scripts/check-openapi-contract.sh', + ); + assert.match( + openapiContract, + /RELEASE_FEATURES=.*canonical-release-features\.txt/, + 'the OpenAPI contract must read the canonical Relay feature profile', + ); + assert.match( + openapiContract, + /--no-default-features --features "\$RELEASE_FEATURES"/, + 'the OpenAPI contract must select the canonical Relay feature profile exactly', ); const openapi = JSON.parse( From 83a96d06cb3f36a2aa488e808a3b6ec6908ed76a Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 11:52:01 +0700 Subject: [PATCH 7/9] fix(relay): address final promotion review Signed-off-by: Jeremi Joslin --- crates/registry-relay/docs/api.md | 7 +++--- .../openapi/registry-relay.openapi.json | 7 ++++-- .../src/api/attribute_release.rs | 16 +++++++++---- crates/registry-relay/src/api/openapi.rs | 19 ++++++++++++--- .../src/attribute_release/mod.rs | 24 ++++++++++++++++++- crates/registry-relay/src/config/mod.rs | 1 + crates/registry-relay/src/config/validate.rs | 4 ++-- .../tests/attribute_release_api.rs | 16 +++++++++++++ .../src/project_authoring/compiler/notary.rs | 7 ++++++ .../src/project_authoring/tests.rs | 10 +++++++- 10 files changed, 95 insertions(+), 16 deletions(-) diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index f96774c95..cf97b080d 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -458,9 +458,10 @@ Request body: } ``` -`claims` is optional: absent resolves the profile's full default claim set; an -explicit empty list is rejected with `400 filter.invalid_value`; a name -outside the profile's configured claims is denied. `subject.value` accepts +`claims` is optional: absent resolves the profile's full default claim set. An +explicit list must contain between 1 and 32 unique claim names; an invalid +list returns `400 filter.invalid_value`. A name outside the profile's +configured claims is denied. `subject.value` accepts only a non-blank scalar (string, number, or boolean); `subject.id_type` must match the profile's configured type. Either failure returns `400 release.subject_invalid`. diff --git a/crates/registry-relay/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index df5e61c47..bc9880d01 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -1268,14 +1268,17 @@ "description": "Request body for resolving an attribute release profile against one subject.", "properties": { "claims": { - "description": "Optional subset of claim names to return. Absent means the profile default set; an empty array is rejected (400); any unknown claim name is denied.", + "description": "Optional subset of claim names to return. Absent means the profile default set; an empty array is rejected (400); duplicate or over-bound arrays are rejected (400); any unknown claim name is denied.", "items": { "type": "string" }, + "maxItems": 32, + "minItems": 1, "type": [ "array", "null" - ] + ], + "uniqueItems": true }, "subject": { "additionalProperties": false, diff --git a/crates/registry-relay/src/api/attribute_release.rs b/crates/registry-relay/src/api/attribute_release.rs index d69836adf..c83305a1a 100644 --- a/crates/registry-relay/src/api/attribute_release.rs +++ b/crates/registry-relay/src/api/attribute_release.rs @@ -41,7 +41,9 @@ use crate::attribute_release::AttributeReleaseEvaluator; use crate::audit::AuditContextExt; use crate::auth::scopes::require_scope; use crate::auth::Principal; -use crate::config::{AttributeReleaseProfile, Config, EntityConfig, ReleaseClaimConfig}; +use crate::config::{ + AttributeReleaseProfile, Config, EntityConfig, ReleaseClaimConfig, MAX_ATTRIBUTE_RELEASE_CLAIMS, +}; use crate::entity::EntityModel; use crate::error::{AuthError, Error, FilterError, ReleaseError, SchemaError}; use crate::query::{EntityCollectionQuery, EntityFilter, EntityQueryEngine}; @@ -502,8 +504,8 @@ fn subject_audit_raw(value: &Value) -> Option { } /// Resolve the effective claim set. Absent ⇒ the profile default (all -/// configured claims); explicit `[]` ⇒ 400 invalid value; any name not in the -/// profile ⇒ deny (`release.subject_denied`). +/// configured claims); an empty, duplicate, or over-bound list ⇒ 400 invalid +/// value; any name not in the profile ⇒ deny (`release.subject_denied`). #[allow(clippy::result_large_err)] fn resolve_requested_claims<'a>( route: &'a RouteState, @@ -514,13 +516,19 @@ fn resolve_requested_claims<'a>( let Some(names) = requested else { return Ok(route.profile.claims.iter().collect()); }; - if names.is_empty() { + if names.is_empty() || names.len() > MAX_ATTRIBUTE_RELEASE_CLAIMS { return Err(ResolveRunError::from(Error::from( FilterError::InvalidValue, ))); } let mut resolved = Vec::with_capacity(names.len()); + let mut unique_names = BTreeSet::new(); for name in names { + if !unique_names.insert(name.as_str()) { + return Err(ResolveRunError::from(Error::from( + FilterError::InvalidValue, + ))); + } match route .profile .claims diff --git a/crates/registry-relay/src/api/openapi.rs b/crates/registry-relay/src/api/openapi.rs index 906aa2e43..f8e8f4b63 100644 --- a/crates/registry-relay/src/api/openapi.rs +++ b/crates/registry-relay/src/api/openapi.rs @@ -16,7 +16,9 @@ use crate::api::governed::{ }; use crate::audit::ErrorCodeExt; use crate::auth::Principal; -use crate::config::{AuthMode, Config, DatasetConfig, EntityConfig, FilterOp}; +use crate::config::{ + AuthMode, Config, DatasetConfig, EntityConfig, FilterOp, MAX_ATTRIBUTE_RELEASE_CLAIMS, +}; use crate::entity::EntityRegistry; use crate::error::{AuthError, Error}; // Reads the local `CatalogDocument`, not `registry-manifest-core`'s @@ -5349,8 +5351,12 @@ fn attribute_release_resolve_request_schema() -> Value { "type": ["array", "null"], "description": "Optional subset of claim names to return. Absent means the \ profile default set; an empty array is rejected (400); \ - any unknown claim name is denied.", - "items": { "type": "string" } + duplicate or over-bound arrays are rejected (400); any \ + unknown claim name is denied.", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": MAX_ATTRIBUTE_RELEASE_CLAIMS, + "uniqueItems": true } }, "additionalProperties": false, @@ -6507,6 +6513,13 @@ mod tests { json!(["string", "number", "boolean"]), "subject value must document every runtime-supported scalar type" ); + let claims_schema = &schemas["AttributeReleaseResolveRequest"]["properties"]["claims"]; + assert_eq!(claims_schema["minItems"], 1); + assert_eq!( + claims_schema["maxItems"], + json!(MAX_ATTRIBUTE_RELEASE_CLAIMS) + ); + assert_eq!(claims_schema["uniqueItems"], true); // Required fields on AttributeReleaseProfile schema let profile_required = &schemas["AttributeReleaseProfile"]["required"]; diff --git a/crates/registry-relay/src/attribute_release/mod.rs b/crates/registry-relay/src/attribute_release/mod.rs index 0a49509be..ec4bbbf2a 100644 --- a/crates/registry-relay/src/attribute_release/mod.rs +++ b/crates/registry-relay/src/attribute_release/mod.rs @@ -261,7 +261,8 @@ mod enabled { } /// Collect dotted or bracketed CEL member roots while ignoring quoted string - /// contents. The reserved `context` root is collected even when bare. + /// contents and CEL line comments. The reserved `context` root is collected + /// even when bare. /// This mirrors Registryctl's authoring validator so direct Relay config and /// generated config enforce the same source-only authority boundary. fn cel_member_roots(expression: &str) -> Result, ()> { @@ -269,6 +270,13 @@ mod enabled { let mut roots = BTreeSet::new(); let mut index = 0; while index < bytes.len() { + if bytes[index] == b'/' && matches!(bytes.get(index + 1), Some(b'/')) { + index += 2; + while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') { + index += 1; + } + continue; + } if matches!(bytes[index], b'\'' | b'"') { let quote = bytes[index]; index += 1; @@ -538,6 +546,20 @@ mod enabled { .expect("quoted member-like text is not authority"); } + #[test] + fn expression_authority_ignores_member_decoys_and_quotes_in_comments() { + validate_release_expression( + r#"source.active // context.secret ' "unterminated +"#, + ) + .expect("line-comment contents are not authority or string syntax"); + + let err = + validate_release_expression("source.active // request.value\n || context.secret") + .expect_err("authority on the next line must still be rejected"); + assert!(matches!(err, AttributeReleaseError::Compile(_))); + } + #[test] fn expression_authority_accepts_nested_and_method_member_chains() { for expression in [ diff --git a/crates/registry-relay/src/config/mod.rs b/crates/registry-relay/src/config/mod.rs index ca9d6685e..e0a00970e 100644 --- a/crates/registry-relay/src/config/mod.rs +++ b/crates/registry-relay/src/config/mod.rs @@ -54,6 +54,7 @@ pub use loader::{ }; pub(crate) const MAX_AUDIT_PSEUDONYM_MATERIALS: usize = 32; +pub(crate) const MAX_ATTRIBUTE_RELEASE_CLAIMS: usize = 32; pub(crate) const MAX_CONSULTATION_SOURCE_CREDENTIALS: usize = 128; /// Root configuration document. Parsed from YAML at startup. diff --git a/crates/registry-relay/src/config/validate.rs b/crates/registry-relay/src/config/validate.rs index c0ef9eaba..38e1f223c 100644 --- a/crates/registry-relay/src/config/validate.rs +++ b/crates/registry-relay/src/config/validate.rs @@ -28,7 +28,7 @@ use super::{ AuditSinkConfig, AuthMode, Config, DatasetConfig, EntityConfig, EntityRelationshipConfig, EntitySpatialConfig, FieldConfig, FieldType, FilterOp, GovernedPolicyConfig, OidcConfig, RefreshConfig, RelationshipKind, ReleaseClaimConfig, ResourceConfig, Sensitivity, SourceConfig, - SpatialBboxFieldsConfig, SpatialGeometryConfig, CRS84, + SpatialBboxFieldsConfig, SpatialGeometryConfig, CRS84, MAX_ATTRIBUTE_RELEASE_CLAIMS, }; /// Product-scoped admin capability required by private admin mutations. @@ -3823,7 +3823,7 @@ fn validate_entity_release_profile( // Claims: non-empty, ≥1 required, unique names, each a valid id, and each // declaring exactly one of source_field / expression.cel. - if profile.claims.is_empty() || profile.claims.len() > 32 { + if profile.claims.is_empty() || profile.claims.len() > MAX_ATTRIBUTE_RELEASE_CLAIMS { return release_error("attribute_release_profiles must declare between one and 32 claims"); } let mut claim_names: HashSet<&str> = HashSet::new(); diff --git a/crates/registry-relay/tests/attribute_release_api.rs b/crates/registry-relay/tests/attribute_release_api.rs index ab082b4d6..1f2f50e3c 100644 --- a/crates/registry-relay/tests/attribute_release_api.rs +++ b/crates/registry-relay/tests/attribute_release_api.rs @@ -417,6 +417,22 @@ async fn resolve_empty_claim_list_is_bad_request() { response.assert_status(StatusCode::BAD_REQUEST); } +#[tokio::test] +async fn resolve_rejects_duplicate_and_over_bound_claim_lists() { + let server = server().await; + for claims in [vec!["full_name"; 2], vec!["full_name"; 33]] { + let response = server + .post(RESOLVE_PATH) + .json(&json!({ + "subject": { "id_type": "NATIONAL_ID", "value": "NID-1" }, + "claims": claims + })) + .await; + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(response.json::()["code"], "filter.invalid_value"); + } +} + #[tokio::test] async fn resolve_unknown_requested_claim_is_denied() { let server = server().await; diff --git a/crates/registryctl/src/project_authoring/compiler/notary.rs b/crates/registryctl/src/project_authoring/compiler/notary.rs index c3101f8de..d39d8e7dc 100644 --- a/crates/registryctl/src/project_authoring/compiler/notary.rs +++ b/crates/registryctl/src/project_authoring/compiler/notary.rs @@ -863,6 +863,13 @@ fn cel_member_roots(expression: &str) -> Result> { let mut roots = BTreeSet::new(); let mut index = 0; while index < bytes.len() { + if bytes[index] == b'/' && matches!(bytes.get(index + 1), Some(b'/')) { + index += 2; + while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') { + index += 1; + } + continue; + } if matches!(bytes[index], b'\'' | b'"') { let quote = bytes[index]; index += 1; diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index c03d06c9a..d3dc3854f 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -2172,7 +2172,7 @@ outputs: } #[test] - fn cel_consultation_roots_ignore_string_literals() { + fn cel_consultation_roots_ignore_strings_and_comments() { assert_eq!( cel_member_roots("'decoy.exists' == 'x' && person.exists").expect("CEL roots parse"), BTreeSet::from(["person".to_string()]) @@ -2185,6 +2185,14 @@ outputs: .expect("nested CEL roots parse"), BTreeSet::from(["person".to_string()]) ); + assert_eq!( + cel_member_roots( + r#"person.exists // decoy.value ' "unterminated +&& another.exists"# + ) + .expect("commented CEL roots parse"), + BTreeSet::from(["another".to_string(), "person".to_string()]) + ); assert!(cel_member_roots("person.exists && 'unterminated").is_err()); } From 2912d383a8ba44bd9ccba21a911dfab8abcc1d23 Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 12:50:17 +0700 Subject: [PATCH 8/9] fix(relay): close final release invariants Signed-off-by: Jeremi Joslin --- Cargo.lock | 2 + Cargo.toml | 1 + crates/registry-relay/Cargo.toml | 3 +- crates/registry-relay/docs/api.md | 3 +- .../openapi/registry-relay.openapi.json | 2 +- .../src/api/attribute_release.rs | 52 +++++-- crates/registry-relay/src/api/openapi.rs | 3 +- .../src/attribute_release/mod.rs | 132 +++++++++++------- .../tests/attribute_release_api.rs | 14 ++ crates/registryctl/Cargo.toml | 1 + crates/registryctl/src/project_authoring.rs | 1 + .../src/project_authoring/compiler/notary.rs | 108 +++++++------- .../src/project_authoring/tests.rs | 12 ++ 13 files changed, 222 insertions(+), 112 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dfc996d6e..dc8c86aa5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5652,6 +5652,7 @@ dependencies = [ "base64", "bytes", "calamine", + "cel", "chrono", "criterion", "crosswalk-core", @@ -5726,6 +5727,7 @@ version = "0.13.0" dependencies = [ "anyhow", "base64", + "cel", "clap", "crc32fast", "ed25519-dalek", diff --git a/Cargo.toml b/Cargo.toml index 6902c0f78..bf7e54c35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -88,6 +88,7 @@ axum-test = { version = "20" } base64 = { version = "0.22" } bytes = { version = "1" } calamine = { version = "0.36" } +cel = { version = "0.13" } chrono = { version = "0.4" } clap = { version = "4", features = ["derive", "env"] } crc32fast = { version = "1.4" } diff --git a/crates/registry-relay/Cargo.toml b/crates/registry-relay/Cargo.toml index 92396ab0a..40bc78c35 100644 --- a/crates/registry-relay/Cargo.toml +++ b/crates/registry-relay/Cargo.toml @@ -18,7 +18,7 @@ ignored = ["humantime-serde"] # recipes still select the exact canonical features explicitly so changing this # developer default cannot silently expand a published binary. default = ["attribute-release"] -attribute-release = ["crosswalk-runtime"] +attribute-release = ["crosswalk-runtime", "dep:cel"] crosswalk-runtime = ["dep:crosswalk-core"] standards-cel-mapping = ["crosswalk-runtime"] spdci-api-standards = ["dep:jsonschema"] @@ -66,6 +66,7 @@ native-tls = { version = "0.2" } # relying on a re-export path. futures = { version = "0.3" } bytes = { version = "1" } +cel = { workspace = true, optional = true } # Atomic state swap for IngestPlan readiness (single-writer, many-reader). arc-swap = { version = "1" } # Implements DataFusion's async TableProvider delegation for atomic swaps. diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index cf97b080d..d6778c259 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -460,7 +460,8 @@ Request body: `claims` is optional: absent resolves the profile's full default claim set. An explicit list must contain between 1 and 32 unique claim names; an invalid -list returns `400 filter.invalid_value`. A name outside the profile's +list returns `400 filter.invalid_value`. Every claim listed by discovery as +required must be present in an explicit list. A name outside the profile's configured claims is denied. `subject.value` accepts only a non-blank scalar (string, number, or boolean); `subject.id_type` must match the profile's configured type. Either failure returns diff --git a/crates/registry-relay/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index bc9880d01..aff82f812 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -1268,7 +1268,7 @@ "description": "Request body for resolving an attribute release profile against one subject.", "properties": { "claims": { - "description": "Optional subset of claim names to return. Absent means the profile default set; an empty array is rejected (400); duplicate or over-bound arrays are rejected (400); any unknown claim name is denied.", + "description": "Optional subset of claim names to return. Absent means the profile default set; an empty array is rejected (400); duplicate or over-bound arrays are rejected (400); any explicit subset must include every required claim; any unknown claim name is denied.", "items": { "type": "string" }, diff --git a/crates/registry-relay/src/api/attribute_release.rs b/crates/registry-relay/src/api/attribute_release.rs index c83305a1a..72927c24d 100644 --- a/crates/registry-relay/src/api/attribute_release.rs +++ b/crates/registry-relay/src/api/attribute_release.rs @@ -92,7 +92,8 @@ where /// Inbound resolve request body. JSON only; a wrong/absent `Content-Type` /// yields 415 and a malformed body 400 through the axum `Json` extractor's /// default rejection. `claims` absent ⇒ profile default set; an explicit empty -/// list is rejected with 400; any unknown requested claim denies. +/// list or a subset missing a required claim is rejected with 400; any unknown +/// requested claim denies. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ResolveRequest { @@ -205,6 +206,20 @@ struct ResolveRunError { } impl ResolveRunError { + fn invalid_claim_request( + subject_id_raw: Option, + pdp_audit: Option, + ) -> Self { + Self { + error: FilterError::InvalidValue.into(), + audit: ResolveAudit { + subject_id_raw, + pdp_audit, + ..ResolveAudit::default() + }, + } + } + /// Build a release-error denial. `ar_internal_outcome` carries the distinct /// internal label; `ar_released_claims` is the empty list on a denied /// outcome so the audit trail records "nothing released". @@ -505,7 +520,8 @@ fn subject_audit_raw(value: &Value) -> Option { /// Resolve the effective claim set. Absent ⇒ the profile default (all /// configured claims); an empty, duplicate, or over-bound list ⇒ 400 invalid -/// value; any name not in the profile ⇒ deny (`release.subject_denied`). +/// value; an explicit subset missing a required claim ⇒ 400 invalid value; any +/// name not in the profile ⇒ deny (`release.subject_denied`). #[allow(clippy::result_large_err)] fn resolve_requested_claims<'a>( route: &'a RouteState, @@ -517,17 +533,19 @@ fn resolve_requested_claims<'a>( return Ok(route.profile.claims.iter().collect()); }; if names.is_empty() || names.len() > MAX_ATTRIBUTE_RELEASE_CLAIMS { - return Err(ResolveRunError::from(Error::from( - FilterError::InvalidValue, - ))); + return Err(ResolveRunError::invalid_claim_request( + subject_id_raw.clone(), + pdp_audit.clone(), + )); } let mut resolved = Vec::with_capacity(names.len()); let mut unique_names = BTreeSet::new(); for name in names { if !unique_names.insert(name.as_str()) { - return Err(ResolveRunError::from(Error::from( - FilterError::InvalidValue, - ))); + return Err(ResolveRunError::invalid_claim_request( + subject_id_raw.clone(), + pdp_audit.clone(), + )); } match route .profile @@ -550,6 +568,17 @@ fn resolve_requested_claims<'a>( } } } + if route + .profile + .claims + .iter() + .any(|claim| claim.required && !unique_names.contains(claim.name.as_str())) + { + return Err(ResolveRunError::invalid_claim_request( + subject_id_raw.clone(), + pdp_audit.clone(), + )); + } Ok(resolved) } @@ -874,6 +903,13 @@ mod tests { assert_eq!(subject_audit_raw(&json!({"a": 1})), None); } + #[test] + fn invalid_claim_request_preserves_subject_for_audit_hashing() { + let error = ResolveRunError::invalid_claim_request(Some("NID-1".to_string()), None); + assert_eq!(error.audit.subject_id_raw.as_deref(), Some("NID-1")); + assert_eq!(error.error.code(), "filter.invalid_value"); + } + #[test] fn every_accepted_subject_has_an_audit_raw() { // The invariant the audit-canonicalization fix guarantees: any value diff --git a/crates/registry-relay/src/api/openapi.rs b/crates/registry-relay/src/api/openapi.rs index f8e8f4b63..a38786a26 100644 --- a/crates/registry-relay/src/api/openapi.rs +++ b/crates/registry-relay/src/api/openapi.rs @@ -5352,7 +5352,8 @@ fn attribute_release_resolve_request_schema() -> Value { "description": "Optional subset of claim names to return. Absent means the \ profile default set; an empty array is rejected (400); \ duplicate or over-bound arrays are rejected (400); any \ - unknown claim name is denied.", + explicit subset must include every required claim; any unknown \ + claim name is denied.", "items": { "type": "string" }, "minItems": 1, "maxItems": MAX_ATTRIBUTE_RELEASE_CLAIMS, diff --git a/crates/registry-relay/src/attribute_release/mod.rs b/crates/registry-relay/src/attribute_release/mod.rs index ec4bbbf2a..52519f6bc 100644 --- a/crates/registry-relay/src/attribute_release/mod.rs +++ b/crates/registry-relay/src/attribute_release/mod.rs @@ -64,6 +64,7 @@ pub use disabled::{ mod enabled { use super::AttributeReleaseError; + use cel::common::ast::{EntryExpr, Expr, IdedExpr}; use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, RwLock}; @@ -260,73 +261,82 @@ mod enabled { Ok(()) } - /// Collect dotted or bracketed CEL member roots while ignoring quoted string - /// contents and CEL line comments. The reserved `context` root is collected - /// even when bare. + /// Collect global CEL roots from the parsed expression while excluding + /// comprehension variables introduced by CEL collection macros. /// This mirrors Registryctl's authoring validator so direct Relay config and /// generated config enforce the same source-only authority boundary. fn cel_member_roots(expression: &str) -> Result, ()> { - let bytes = expression.as_bytes(); + let program = cel::Program::compile(expression).map_err(|_| ())?; let mut roots = BTreeSet::new(); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'/' && matches!(bytes.get(index + 1), Some(b'/')) { - index += 2; - while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') { - index += 1; + collect_cel_roots(program.expression(), &BTreeSet::new(), &mut roots); + Ok(roots) + } + + fn collect_cel_roots( + expression: &IdedExpr, + locals: &BTreeSet, + roots: &mut BTreeSet, + ) { + match &expression.expr { + Expr::Unspecified | Expr::Literal(_) => {} + Expr::Ident(name) => { + if !name.starts_with('@') && !locals.contains(name) { + roots.insert(name.clone()); } - continue; } - if matches!(bytes[index], b'\'' | b'"') { - let quote = bytes[index]; - index += 1; - let mut escaped = false; - let mut closed = false; - while index < bytes.len() { - let byte = bytes[index]; - index += 1; - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if byte == quote { - closed = true; - break; - } + Expr::Select(select) => collect_cel_roots(&select.operand, locals, roots), + Expr::Call(call) => { + if let Some(target) = &call.target { + collect_cel_roots(target, locals, roots); } - if !closed { - return Err(()); + for argument in &call.args { + collect_cel_roots(argument, locals, roots); } - continue; } - if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' { - let start = index; - index += 1; - while index < bytes.len() - && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') - { - index += 1; + Expr::List(list) => { + for element in &list.elements { + collect_cel_roots(element, locals, roots); } - let mut member_index = index; - while bytes.get(member_index).is_some_and(u8::is_ascii_whitespace) { - member_index += 1; + } + Expr::Map(map) => { + for entry in &map.entries { + collect_cel_entry_roots(&entry.expr, locals, roots); } - let mut root_index = start; - while root_index > 0 && bytes[root_index - 1].is_ascii_whitespace() { - root_index -= 1; + } + Expr::Struct(value) => { + for entry in &value.entries { + collect_cel_entry_roots(&entry.expr, locals, roots); } - let follows_member_access = root_index > 0 && bytes[root_index - 1] == b'.'; - if !follows_member_access - && (&expression[start..index] == "context" - || matches!(bytes.get(member_index), Some(b'.' | b'['))) - { - roots.insert(expression[start..index].to_string()); + } + Expr::Comprehension(comprehension) => { + collect_cel_roots(&comprehension.iter_range, locals, roots); + collect_cel_roots(&comprehension.accu_init, locals, roots); + + let mut scoped_locals = locals.clone(); + scoped_locals.insert(comprehension.iter_var.clone()); + if let Some(iter_var) = &comprehension.iter_var2 { + scoped_locals.insert(iter_var.clone()); } - continue; + scoped_locals.insert(comprehension.accu_var.clone()); + collect_cel_roots(&comprehension.loop_cond, &scoped_locals, roots); + collect_cel_roots(&comprehension.loop_step, &scoped_locals, roots); + collect_cel_roots(&comprehension.result, &scoped_locals, roots); + } + } + } + + fn collect_cel_entry_roots( + entry: &EntryExpr, + locals: &BTreeSet, + roots: &mut BTreeSet, + ) { + match entry { + EntryExpr::StructField(field) => collect_cel_roots(&field.value, locals, roots), + EntryExpr::MapEntry(entry) => { + collect_cel_roots(&entry.key, locals, roots); + collect_cel_roots(&entry.value, locals, roots); } - index += 1; } - Ok(roots) } /// Evaluate a release **predicate** over a subject record. Fails closed: @@ -574,6 +584,24 @@ mod enabled { } } + #[test] + fn expression_authority_scopes_cel_macro_locals() { + let expression = "source.items.exists(item, item.active)"; + assert_eq!( + cel_member_roots(expression).expect("CEL roots parse"), + BTreeSet::from(["source".to_string()]) + ); + validate_release_expression(expression).expect("macro local derives from source"); + + assert_eq!( + cel_member_roots( + "source.items.exists(item, item.active) && item.secret == 'outside'" + ) + .expect("CEL roots parse"), + BTreeSet::from(["item".to_string(), "source".to_string()]) + ); + } + #[test] fn expression_authority_rejects_unterminated_strings() { let err = validate_release_expression("source.given_name == 'unterminated") diff --git a/crates/registry-relay/tests/attribute_release_api.rs b/crates/registry-relay/tests/attribute_release_api.rs index 1f2f50e3c..03655359c 100644 --- a/crates/registry-relay/tests/attribute_release_api.rs +++ b/crates/registry-relay/tests/attribute_release_api.rs @@ -433,6 +433,20 @@ async fn resolve_rejects_duplicate_and_over_bound_claim_lists() { } } +#[tokio::test] +async fn resolve_rejects_subset_missing_required_claims() { + let server = server().await; + let response = server + .post(RESOLVE_PATH) + .json(&json!({ + "subject": { "id_type": "NATIONAL_ID", "value": "NID-1" }, + "claims": ["full_name"] + })) + .await; + response.assert_status(StatusCode::BAD_REQUEST); + assert_eq!(response.json::()["code"], "filter.invalid_value"); +} + #[tokio::test] async fn resolve_unknown_requested_claim_is_denied() { let server = server().await; diff --git a/crates/registryctl/Cargo.toml b/crates/registryctl/Cargo.toml index 6e712feb9..be99021bc 100644 --- a/crates/registryctl/Cargo.toml +++ b/crates/registryctl/Cargo.toml @@ -15,6 +15,7 @@ relay-contract-test-support = ["registry-notary-server/relay-contract-test-suppo [dependencies] anyhow = "1" base64 = "0.22" +cel.workspace = true clap = { version = "4.5", features = ["derive"] } crc32fast = "1.4" ed25519-dalek.workspace = true diff --git a/crates/registryctl/src/project_authoring.rs b/crates/registryctl/src/project_authoring.rs index 641f13379..275f8bfd7 100644 --- a/crates/registryctl/src/project_authoring.rs +++ b/crates/registryctl/src/project_authoring.rs @@ -8,6 +8,7 @@ use std::io::{Read as _, Write as _}; use std::path::{Component, Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; +use cel::common::ast::{EntryExpr, Expr, IdedExpr}; use clap::ValueEnum; use registry_notary_core::{config::StatePostgresqlConfig, StandaloneRegistryNotaryConfig}; use registry_platform_crypto::{canonicalize_json, parse_json_strict}; diff --git a/crates/registryctl/src/project_authoring/compiler/notary.rs b/crates/registryctl/src/project_authoring/compiler/notary.rs index d39d8e7dc..6d037eecb 100644 --- a/crates/registryctl/src/project_authoring/compiler/notary.rs +++ b/crates/registryctl/src/project_authoring/compiler/notary.rs @@ -859,66 +859,78 @@ fn claim_consultation_name<'a>( } fn cel_member_roots(expression: &str) -> Result> { - let bytes = expression.as_bytes(); + let program = cel::Program::compile(expression) + .map_err(|_| anyhow!("CEL expression contains invalid syntax"))?; let mut roots = BTreeSet::new(); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'/' && matches!(bytes.get(index + 1), Some(b'/')) { - index += 2; - while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') { - index += 1; + collect_cel_roots(program.expression(), &BTreeSet::new(), &mut roots); + Ok(roots) +} + +fn collect_cel_roots( + expression: &IdedExpr, + locals: &BTreeSet, + roots: &mut BTreeSet, +) { + match &expression.expr { + Expr::Unspecified | Expr::Literal(_) => {} + Expr::Ident(name) => { + if !name.starts_with('@') && !locals.contains(name) { + roots.insert(name.clone()); } - continue; } - if matches!(bytes[index], b'\'' | b'"') { - let quote = bytes[index]; - index += 1; - let mut escaped = false; - let mut closed = false; - while index < bytes.len() { - let byte = bytes[index]; - index += 1; - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if byte == quote { - closed = true; - break; - } + Expr::Select(select) => collect_cel_roots(&select.operand, locals, roots), + Expr::Call(call) => { + if let Some(target) = &call.target { + collect_cel_roots(target, locals, roots); } - if !closed { - bail!("CEL expression contains an unterminated string literal"); + for argument in &call.args { + collect_cel_roots(argument, locals, roots); } - continue; } - if bytes[index].is_ascii_alphabetic() || bytes[index] == b'_' { - let start = index; - index += 1; - while index < bytes.len() - && (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_') - { - index += 1; + Expr::List(list) => { + for element in &list.elements { + collect_cel_roots(element, locals, roots); } - let mut member_index = index; - while bytes.get(member_index).is_some_and(u8::is_ascii_whitespace) { - member_index += 1; + } + Expr::Map(map) => { + for entry in &map.entries { + collect_cel_entry_roots(&entry.expr, locals, roots); } - let mut root_index = start; - while root_index > 0 && bytes[root_index - 1].is_ascii_whitespace() { - root_index -= 1; + } + Expr::Struct(value) => { + for entry in &value.entries { + collect_cel_entry_roots(&entry.expr, locals, roots); } - let follows_member_access = root_index > 0 && bytes[root_index - 1] == b'.'; - if !follows_member_access - && matches!(bytes.get(member_index), Some(b'.' | b'[')) - { - roots.insert(expression[start..index].to_string()); + } + Expr::Comprehension(comprehension) => { + collect_cel_roots(&comprehension.iter_range, locals, roots); + collect_cel_roots(&comprehension.accu_init, locals, roots); + + let mut scoped_locals = locals.clone(); + scoped_locals.insert(comprehension.iter_var.clone()); + if let Some(iter_var) = &comprehension.iter_var2 { + scoped_locals.insert(iter_var.clone()); } - continue; + scoped_locals.insert(comprehension.accu_var.clone()); + collect_cel_roots(&comprehension.loop_cond, &scoped_locals, roots); + collect_cel_roots(&comprehension.loop_step, &scoped_locals, roots); + collect_cel_roots(&comprehension.result, &scoped_locals, roots); + } + } +} + +fn collect_cel_entry_roots( + entry: &EntryExpr, + locals: &BTreeSet, + roots: &mut BTreeSet, +) { + match entry { + EntryExpr::StructField(field) => collect_cel_roots(&field.value, locals, roots), + EntryExpr::MapEntry(entry) => { + collect_cel_roots(&entry.key, locals, roots); + collect_cel_roots(&entry.value, locals, roots); } - index += 1; } - Ok(roots) } fn expanded_disclosure(disclosure: &DisclosureDeclaration) -> (&str, Vec<&str>) { diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index d3dc3854f..8a450b1a0 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -2193,6 +2193,18 @@ outputs: .expect("commented CEL roots parse"), BTreeSet::from(["another".to_string(), "person".to_string()]) ); + assert_eq!( + cel_member_roots("person.items.exists(item, item.active)") + .expect("macro-local CEL roots parse"), + BTreeSet::from(["person".to_string()]) + ); + assert_eq!( + cel_member_roots( + "person.items.exists(item, item.active) && item.secret == 'outside'" + ) + .expect("out-of-scope CEL roots parse"), + BTreeSet::from(["item".to_string(), "person".to_string()]) + ); assert!(cel_member_roots("person.exists && 'unterminated").is_err()); } From 9e15035894aa1de4eade294bef834aaff8d43d1c Mon Sep 17 00:00:00 2001 From: Jeremi Joslin Date: Sun, 26 Jul 2026 15:50:18 +0700 Subject: [PATCH 9/9] fix(relay): tighten attribute release promotion contracts Signed-off-by: Jeremi Joslin --- crates/registry-relay/CHANGELOG.md | 4 +- crates/registry-relay/Cargo.toml | 3 + crates/registry-relay/Dockerfile | 6 +- crates/registry-relay/README.md | 6 +- crates/registry-relay/build.rs | 44 ++++++++++ crates/registry-relay/build_support.rs | 82 +++++++++++++++++++ crates/registry-relay/deny.toml | 4 +- crates/registry-relay/docs/api.md | 9 +- crates/registry-relay/docs/ops.md | 10 +-- crates/registry-relay/docs/release-notes.md | 37 +++++++++ .../openapi/registry-relay.openapi.json | 2 +- .../scripts/check_docker_build_contract.py | 21 +++-- .../scripts/check_security_assurance.py | 5 +- .../scripts/validate-feature-profile.sh | 18 ++-- .../src/api/attribute_release.rs | 77 ++++++++++++++--- crates/registry-relay/src/api/openapi.rs | 4 +- crates/registry-relay/src/config/validate.rs | 15 +++- crates/registry-relay/src/error.rs | 21 +++++ crates/registry-relay/src/lib.rs | 26 ++---- .../tests/attribute_release_api.rs | 56 +++++++++++++ .../registry-relay/tests/config_entities.rs | 20 +++++ crates/registry-relay/tests/error_taxonomy.rs | 17 ++++ .../registry-relay/tests/feature_profile.rs | 46 +++++++++++ crates/registryctl/CHANGELOG.md | 3 +- .../src/project_authoring/output.rs | 14 ++++ .../src/project_authoring/project.rs | 2 +- .../src/project_authoring/tests.rs | 33 ++++++++ deny.toml | 4 +- release/scripts/build-release-binaries.sh | 3 +- release/scripts/test_registry_release.py | 4 + 30 files changed, 522 insertions(+), 74 deletions(-) create mode 100644 crates/registry-relay/build.rs create mode 100644 crates/registry-relay/build_support.rs create mode 100644 crates/registry-relay/tests/feature_profile.rs diff --git a/crates/registry-relay/CHANGELOG.md b/crates/registry-relay/CHANGELOG.md index 2fb91ea27..425800fa6 100644 --- a/crates/registry-relay/CHANGELOG.md +++ b/crates/registry-relay/CHANGELOG.md @@ -7,7 +7,9 @@ subject-type bindings; remove inert pre-1.0 configuration fields; evaluate release expressions only over redacted rows; prevent profile enumeration; disable response storage; and publish compiled feature evidence through the - admin capabilities document and Relay image metadata. + admin capabilities document and Relay image metadata. Profile versions use a + portable URL-path grammar, and resolve JSON rejections now return Relay + Problem Details with non-storable headers. ## 0.13.0 - 2026-07-25 diff --git a/crates/registry-relay/Cargo.toml b/crates/registry-relay/Cargo.toml index 40bc78c35..288e216b3 100644 --- a/crates/registry-relay/Cargo.toml +++ b/crates/registry-relay/Cargo.toml @@ -13,6 +13,9 @@ workspace = true [package.metadata.cargo-machete] ignored = ["humantime-serde"] +[build-dependencies] +toml = { version = "1.1" } + [features] # The 1.0 default build includes the stable attribute-release surface. Release # recipes still select the exact canonical features explicitly so changing this diff --git a/crates/registry-relay/Dockerfile b/crates/registry-relay/Dockerfile index afcdef46f..27c51e772 100644 --- a/crates/registry-relay/Dockerfile +++ b/crates/registry-relay/Dockerfile @@ -16,6 +16,7 @@ COPY --from=crosswalk /Cargo.toml /Cargo.lock /workspace/crosswalk/ COPY --from=crosswalk /crates /workspace/crosswalk/crates COPY benches ./benches COPY resources ./resources +COPY build.rs build_support.rs ./ COPY scripts/validate-feature-profile.sh ./scripts/validate-feature-profile.sh COPY src ./src @@ -24,9 +25,10 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ find src benches resources -type f -exec touch {} + && \ sh scripts/validate-feature-profile.sh "$REGISTRY_RELAY_FEATURES" && \ if [ -n "$REGISTRY_RELAY_FEATURES" ]; then \ - cargo build --release --locked --no-default-features --features "$REGISTRY_RELAY_FEATURES"; \ + REGISTRY_RELAY_FEATURES="$REGISTRY_RELAY_FEATURES" \ + cargo build --release --locked --no-default-features --features "$REGISTRY_RELAY_FEATURES"; \ else \ - cargo build --release --locked --no-default-features; \ + REGISTRY_RELAY_FEATURES="" cargo build --release --locked --no-default-features; \ fi && \ cp /workspace/registry_relay/target/release/registry-relay /usr/local/bin/registry-relay && \ cp /workspace/registry_relay/target/release/registry-relay-rhai-worker /usr/local/bin/registry-relay-rhai-worker && \ diff --git a/crates/registry-relay/README.md b/crates/registry-relay/README.md index ca6683720..307674ede 100644 --- a/crates/registry-relay/README.md +++ b/crates/registry-relay/README.md @@ -110,9 +110,9 @@ The production image is distroless, non-root, and built with the feature set in [`canonical-release-features.txt`](canonical-release-features.txt), currently `attribute-release,crosswalk-runtime`. A custom `REGISTRY_RELAY_FEATURES` value replaces that exact set. Custom profiles must -list transitive product features explicitly, so `attribute-release` and -`standards-cel-mapping` each require `crosswalk-runtime`; incomplete or -duplicate profiles fail the image build. Build steps, sibling-checkout +list product features in alphabetical order and include Cargo's complete +transitive feature closure. Incomplete, unordered, or duplicate profiles fail +the image build before an inaccurate feature label can be published. Build steps, sibling-checkout requirements, and promotion gates are in [docs/ops.md](docs/ops.md#build-and-release); image publication, tagging, and signing policy are in [docs/security-assurance.md](docs/security-assurance.md). diff --git a/crates/registry-relay/build.rs b/crates/registry-relay/build.rs new file mode 100644 index 000000000..5a86c8227 --- /dev/null +++ b/crates/registry-relay/build.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: Apache-2.0 + +mod build_support; + +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=Cargo.toml"); + println!("cargo:rerun-if-env-changed=REGISTRY_RELAY_FEATURES"); + + let manifest = + fs::read_to_string("Cargo.toml").expect("registry-relay Cargo.toml must be readable"); + let declared = build_support::declared_feature_names(&manifest) + .expect("registry-relay Cargo features must be readable"); + let mut enabled = declared + .iter() + .filter(|feature| { + let environment_name = + format!("CARGO_FEATURE_{}", feature.replace('-', "_").to_uppercase()); + env::var_os(environment_name).is_some() + }) + .cloned() + .collect::>(); + enabled.sort(); + + if let Ok(requested) = env::var("REGISTRY_RELAY_FEATURES") { + build_support::validate_requested_profile(&requested, &declared, &enabled) + .unwrap_or_else(|error| panic!("invalid Registry Relay feature profile: {error}")); + } + + let generated = format!( + "const COMPILED_CARGO_FEATURES: &[&str] = &[\n{}\n];\n", + enabled + .iter() + .map(|feature| format!(" {feature:?},")) + .collect::>() + .join("\n") + ); + let output = PathBuf::from(env::var_os("OUT_DIR").expect("Cargo must provide OUT_DIR")) + .join("compiled_cargo_features.rs"); + fs::write(output, generated).expect("compiled feature inventory must be writable"); +} diff --git a/crates/registry-relay/build_support.rs b/crates/registry-relay/build_support.rs new file mode 100644 index 000000000..e6f28dec3 --- /dev/null +++ b/crates/registry-relay/build_support.rs @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::BTreeSet; + +pub fn declared_feature_names(manifest: &str) -> Result, String> { + let document = manifest + .parse::() + .map_err(|error| format!("Cargo.toml is not valid TOML: {error}"))?; + let features = document + .get("features") + .and_then(toml::Value::as_table) + .ok_or_else(|| "Cargo.toml must contain a [features] table".to_string())?; + Ok(features + .keys() + .filter(|feature| feature.as_str() != "default") + .cloned() + .collect()) +} + +pub fn validate_requested_profile( + requested: &str, + declared: &[String], + enabled: &[String], +) -> Result<(), String> { + if requested + .bytes() + .any(|byte| !matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b',')) + { + return Err("use comma-separated Cargo feature names".to_string()); + } + if requested.starts_with(',') || requested.ends_with(',') || requested.contains(",,") { + return Err("feature list must not contain empty entries".to_string()); + } + + let requested = if requested.is_empty() { + Vec::new() + } else { + requested.split(',').map(str::to_string).collect::>() + }; + let requested_set = requested.iter().collect::>(); + if requested_set.len() != requested.len() { + return Err("feature list must not contain duplicates".to_string()); + } + + let mut canonical = requested.clone(); + canonical.sort(); + if requested != canonical { + return Err(format!( + "feature list must use canonical order: {}", + canonical.join(",") + )); + } + + let declared = declared.iter().collect::>(); + let unknown = requested + .iter() + .filter(|feature| !declared.contains(feature)) + .cloned() + .collect::>(); + if !unknown.is_empty() { + return Err(format!("unknown Cargo features: {}", unknown.join(","))); + } + + let enabled = enabled.iter().collect::>(); + let missing = enabled + .difference(&requested_set) + .map(|feature| feature.as_str()) + .collect::>(); + let inactive = requested_set + .difference(&enabled) + .map(|feature| feature.as_str()) + .collect::>(); + if !missing.is_empty() || !inactive.is_empty() { + return Err(format!( + "requested profile does not match Cargo's effective feature set \ + (missing: [{}], inactive: [{}])", + missing.join(","), + inactive.join(",") + )); + } + Ok(()) +} diff --git a/crates/registry-relay/deny.toml b/crates/registry-relay/deny.toml index 8fb5b0301..aa8bc2d83 100644 --- a/crates/registry-relay/deny.toml +++ b/crates/registry-relay/deny.toml @@ -70,7 +70,9 @@ workspace-default-features = "allow" external-default-features = "allow" allow = [] allow-workspace = false -deny = [] +deny = [ + { name = "cel", deny-multiple-versions = true }, +] skip = [] skip-tree = [] diff --git a/crates/registry-relay/docs/api.md b/crates/registry-relay/docs/api.md index d6778c259..c98b6d615 100644 --- a/crates/registry-relay/docs/api.md +++ b/crates/registry-relay/docs/api.md @@ -439,7 +439,8 @@ profile's `release_scope`. The response never includes source internals `POST /v1/attribute-releases/{profile_id}/versions/{version}/resolve` resolves one subject against the named `(profile_id, version)` pair, which is globally -unique and has no "latest" alias. Each profile declares its own +unique and has no "latest" alias. Profile versions use the portable path +segment grammar `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`. Each profile declares its own `release_scope`, a dataset-bound scope that must differ from the entity's `read_scope`; the required scope for this capability is `:identity_release`, one of the scope levels an API key can be @@ -467,6 +468,12 @@ only a non-blank scalar (string, number, or boolean); `subject.id_type` must match the profile's configured type. Either failure returns `400 release.subject_invalid`. +The body must use `Content-Type: application/json`. A missing or unsupported +media type returns `415 release.unsupported_media_type`; malformed JSON or a +body outside the closed request schema returns `400 release.invalid_request`. +Both use Relay Problem Details and carry the same non-storable response headers +as every other resolve outcome. + Successful response (`200`): ```json diff --git a/crates/registry-relay/docs/ops.md b/crates/registry-relay/docs/ops.md index 1cd7289b8..e3406f175 100644 --- a/crates/registry-relay/docs/ops.md +++ b/crates/registry-relay/docs/ops.md @@ -204,14 +204,14 @@ currently `attribute-release,crosswalk-runtime`. A custom release or lab images must retain the canonical features explicitly: ```sh -REGISTRY_RELAY_FEATURES=attribute-release,crosswalk-runtime,spdci-api-standards,standards-cel-mapping,ogcapi-edr \ +REGISTRY_RELAY_FEATURES=attribute-release,crosswalk-runtime,ogcapi-edr,spdci-api-standards,standards-cel-mapping \ scripts/build-image.sh registry-relay:-standards ``` -Custom profiles use unique comma-separated Cargo feature names and must name -transitive product features explicitly. Both `attribute-release` and -`standards-cel-mapping` require `crosswalk-runtime`; the image build rejects an -incomplete profile so its feature label stays consistent with +Custom profiles use unique comma-separated Cargo feature names in alphabetical +order and must name the complete transitive product feature closure. The Cargo +build rejects any requested profile that differs from the effective compiled +set, so its feature label stays consistent with `/admin/v1/capabilities`. If release notes claim SP DCI, standards CEL mapping, or OGC EDR support, record diff --git a/crates/registry-relay/docs/release-notes.md b/crates/registry-relay/docs/release-notes.md index 0b259a889..664f61a15 100644 --- a/crates/registry-relay/docs/release-notes.md +++ b/crates/registry-relay/docs/release-notes.md @@ -11,6 +11,43 @@ response is `private, no-store`, and unauthorized profile lookups are indistinguishable from unknown profiles. + Migrate each pre-release profile by removing the retired hints and making + the enforced bindings explicit: + + ```yaml + # Before + release_scope: civil_registry:release + subject: + input: subject_id + source_field: national_id + cardinality: one + release_conditions: + expression: { cel: "source.active" } + denied_code: inactive + claims: + - name: given_name + source_field: given_name + required: true + shareable: true + response: + max_age_seconds: 300 + + # After + purpose: identity_verification + release_scope: civil_registry:identity_release + subject: + source_field: national_id + id_type: NATIONAL_ID + release_conditions: + expression: { cel: "source.active" } + claims: + - name: given_name + source_field: given_name + required: true + response: + include_source_metadata: false + ``` + ## 0.13.0 - BREAKING: Relay consultation results remove the inert diff --git a/crates/registry-relay/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index aff82f812..6d095c87e 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -5498,7 +5498,7 @@ } } }, - "description": "Invalid request: missing Data-Purpose header, malformed body, unknown id_type, or empty claims list." + "description": "Invalid request: missing Data-Purpose header, malformed or schema-invalid body, unknown id_type, or invalid claims list." }, "401": { "content": { diff --git a/crates/registry-relay/scripts/check_docker_build_contract.py b/crates/registry-relay/scripts/check_docker_build_contract.py index c2f7068f0..516b2dbe6 100755 --- a/crates/registry-relay/scripts/check_docker_build_contract.py +++ b/crates/registry-relay/scripts/check_docker_build_contract.py @@ -77,11 +77,12 @@ def check_feature_profile_script(path: Path) -> list[str]: cases = [ ("", True), ("attribute-release,crosswalk-runtime", True), - ("standards-cel-mapping,crosswalk-runtime", True), + ("crosswalk-runtime,standards-cel-mapping", True), ("ogcapi-edr", True), - ("attribute-release", False), - ("standards-cel-mapping", False), + ("attribute-release", True), + ("standards-cel-mapping", True), ("attribute-release,crosswalk-runtime,attribute-release", False), + ("crosswalk-runtime,attribute-release", False), ("attribute-release crosswalk-runtime", False), ] for profile, expected_success in cases: @@ -140,7 +141,8 @@ def main() -> int: failures.extend( require( dockerfile, - 'cargo build --release --locked --no-default-features --features "$REGISTRY_RELAY_FEATURES"', + 'REGISTRY_RELAY_FEATURES="$REGISTRY_RELAY_FEATURES" \\\n' + ' cargo build --release --locked --no-default-features --features "$REGISTRY_RELAY_FEATURES"', "feature-enabled cargo build path", ) ) @@ -151,11 +153,18 @@ def main() -> int: "feature profile validator copy", ) ) + failures.extend( + require( + dockerfile, + "COPY build.rs build_support.rs ./", + "Cargo-derived compiled feature inventory", + ) + ) failures.extend( require( dockerfile, 'sh scripts/validate-feature-profile.sh "$REGISTRY_RELAY_FEATURES"', - "feature dependency closure validation", + "feature profile syntax validation", ) ) failures.extend( @@ -168,7 +177,7 @@ def main() -> int: failures.extend( require( dockerfile, - "cargo build --release --locked --no-default-features", + 'REGISTRY_RELAY_FEATURES="" cargo build --release --locked --no-default-features', "explicit minimal cargo build path", ) ) diff --git a/crates/registry-relay/scripts/check_security_assurance.py b/crates/registry-relay/scripts/check_security_assurance.py index 7d412acf8..98eaa8be7 100755 --- a/crates/registry-relay/scripts/check_security_assurance.py +++ b/crates/registry-relay/scripts/check_security_assurance.py @@ -109,10 +109,11 @@ def load_release_features() -> set[str]: not release_features or any(not RELEASE_FEATURE_RE.fullmatch(feature) for feature in release_features) or len(set(release_features)) != len(release_features) + or release_features != sorted(release_features) ): fail( - "canonical-release-features.txt must contain unique comma-separated " - "Cargo feature names" + "canonical-release-features.txt must contain unique, sorted, " + "comma-separated Cargo feature names" ) enabled = set(release_features) diff --git a/crates/registry-relay/scripts/validate-feature-profile.sh b/crates/registry-relay/scripts/validate-feature-profile.sh index 6e6382279..22243f396 100755 --- a/crates/registry-relay/scripts/validate-feature-profile.sh +++ b/crates/registry-relay/scripts/validate-feature-profile.sh @@ -18,14 +18,8 @@ case "$features" in ;; esac -has_feature() { - case ",$features," in - *,"$1",*) return 0 ;; - *) return 1 ;; - esac -} - seen="," +previous="" if [ -n "$features" ]; then previous_ifs=$IFS IFS=, @@ -34,12 +28,10 @@ if [ -n "$features" ]; then *,"$feature",*) fail "duplicate feature: $feature" ;; esac seen="${seen}${feature}," + if [ -n "$previous" ] && [ "$previous" \> "$feature" ]; then + fail "feature list must use canonical alphabetical order" + fi + previous=$feature done IFS=$previous_ifs fi - -for feature in attribute-release standards-cel-mapping; do - if has_feature "$feature" && ! has_feature crosswalk-runtime; then - fail "$feature requires crosswalk-runtime" - fi -done diff --git a/crates/registry-relay/src/api/attribute_release.rs b/crates/registry-relay/src/api/attribute_release.rs index 72927c24d..59bf8e187 100644 --- a/crates/registry-relay/src/api/attribute_release.rs +++ b/crates/registry-relay/src/api/attribute_release.rs @@ -20,6 +20,7 @@ use std::collections::BTreeSet; use std::sync::Arc; +use axum::extract::rejection::JsonRejection; use axum::extract::{FromRequestParts, Json, Path}; use axum::http::request::Parts; use axum::http::HeaderMap; @@ -82,18 +83,21 @@ where S: Clone + Send + Sync + 'static, { Router::new() - .route("/v1/attribute-releases", get(discovery)) .route( "/v1/attribute-releases/{profile_id}/versions/{version}/resolve", post(resolve), ) + .route_layer(axum::middleware::map_response(|response| async move { + with_release_cache_headers(response) + })) + .route("/v1/attribute-releases", get(discovery)) } /// Inbound resolve request body. JSON only; a wrong/absent `Content-Type` -/// yields 415 and a malformed body 400 through the axum `Json` extractor's -/// default rejection. `claims` absent ⇒ profile default set; an explicit empty -/// list or a subset missing a required claim is rejected with 400; any unknown -/// requested claim denies. +/// yields 415 and a malformed or schema-invalid body yields 400 through the +/// Relay Problem Details contract. `claims` absent ⇒ profile default set; an +/// explicit empty list or a subset missing a required claim is rejected with +/// 400; any unknown requested claim denies. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct ResolveRequest { @@ -113,7 +117,7 @@ async fn resolve( Path((profile_id, version)): Path<(String, String)>, headers: HeaderMap, deps: RouteDeps, - Json(body): Json, + request: Result, JsonRejection>, ) -> Response { let RouteDeps { runtime, principal } = deps; let Some(principal) = principal else { @@ -144,6 +148,28 @@ async fn resolve( ); return with_release_cache_headers(response); } + let body = match request { + Ok(Json(body)) => body, + Err(rejection) => { + let error = if rejection.status() == axum::http::StatusCode::UNSUPPORTED_MEDIA_TYPE { + Error::from(ReleaseError::UnsupportedMediaType) + } else if rejection.status() == axum::http::StatusCode::PAYLOAD_TOO_LARGE { + Error::from(crate::error::InternalError::PayloadTooLarge) + } else { + Error::from(ReleaseError::InvalidRequest) + }; + let internal_outcome = error.code().to_string(); + let response = with_audit_context( + error.into_response(), + &route, + ResolveAudit { + internal_outcome: Some(internal_outcome), + ..ResolveAudit::default() + }, + ); + return with_release_cache_headers(response); + } + }; let result = run_resolve(&runtime, &route, &headers, Some(principal), body).await; match result { Ok(success) => { @@ -206,6 +232,16 @@ struct ResolveRunError { } impl ResolveRunError { + fn governed_request(error: impl Into, pdp_audit: Option) -> Self { + Self { + error: error.into(), + audit: ResolveAudit { + pdp_audit, + ..ResolveAudit::default() + }, + } + } + fn invalid_claim_request( subject_id_raw: Option, pdp_audit: Option, @@ -280,9 +316,10 @@ async fn run_resolve( ) -> Result { let principal_ref = principal.as_ref().map(|Extension(principal)| principal); - // 1 + 2: authenticate, then require the dataset-bound release scope. The - // release scope is distinct from the entity read scope, so a caller holding - // only `:rows` is denied here before any source read. + // Defence in depth: the handler already authenticates and checks this scope + // to preserve profile anti-enumeration. Recheck it here before governed + // access and any source read so this internal runner remains safe if reused. + // The release scope is distinct from the entity read scope. require_release_scope(principal_ref, &route.profile.release_scope)?; // 3 + 4: purpose + ODRL policy enforced atomically. `DeferredOutput` @@ -309,12 +346,16 @@ async fn run_resolve( match purpose_header_value(headers) { Some(value) if value == route.profile.purpose => {} Some(_) => { - return Err(ResolveRunError::from(Error::from(AuthError::PurposeDenied))); + return Err(ResolveRunError::governed_request( + AuthError::PurposeDenied, + pdp_audit, + )); } None => { - return Err(ResolveRunError::from(Error::from( + return Err(ResolveRunError::governed_request( AuthError::PurposeRequired, - ))); + pdp_audit, + )); } } @@ -910,6 +951,18 @@ mod tests { assert_eq!(error.error.code(), "filter.invalid_value"); } + #[test] + fn profile_purpose_denial_preserves_prior_pdp_audit() { + let audit = PdpDecisionAudit { + policy_id: "release-policy".to_string(), + ..PdpDecisionAudit::default() + }; + let error = + ResolveRunError::governed_request(AuthError::PurposeDenied, Some(audit.clone())); + assert_eq!(error.error.code(), "auth.purpose_denied"); + assert_eq!(error.audit.pdp_audit, Some(audit)); + } + #[test] fn every_accepted_subject_has_an_audit_raw() { // The invariant the audit-canonicalization fix guarantees: any value diff --git a/crates/registry-relay/src/api/openapi.rs b/crates/registry-relay/src/api/openapi.rs index a38786a26..ebb6592e2 100644 --- a/crates/registry-relay/src/api/openapi.rs +++ b/crates/registry-relay/src/api/openapi.rs @@ -4153,8 +4153,8 @@ fn insert_attribute_release_paths(paths: &mut Map) { } }, "400": problem_response( - "Invalid request: missing Data-Purpose header, malformed body, unknown \ - id_type, or empty claims list." + "Invalid request: missing Data-Purpose header, malformed or \ + schema-invalid body, unknown id_type, or invalid claims list." ), "401": problem_response("Missing or invalid bearer credential."), "403": problem_response( diff --git a/crates/registry-relay/src/config/validate.rs b/crates/registry-relay/src/config/validate.rs index 38e1f223c..6582bfc1e 100644 --- a/crates/registry-relay/src/config/validate.rs +++ b/crates/registry-relay/src/config/validate.rs @@ -3707,6 +3707,16 @@ fn is_bounded_release_token(value: &str, max_bytes: usize) -> bool { .any(|character| character.is_control() || character.is_whitespace()) } +fn is_valid_release_version(value: &str) -> bool { + let mut bytes = value.bytes(); + !value.is_empty() + && value.len() <= 64 + && matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9')) + && bytes.all( + |byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-'), + ) +} + fn is_bounded_release_text(value: &str) -> bool { !value.is_empty() && value.len() <= 2048 @@ -3769,9 +3779,10 @@ fn validate_entity_release_profile( if profile.id.len() > 96 || !is_valid_profile_id(&profile.id) { return release_error("attribute_release_profiles id must match ^[a-z][a-z0-9_-]{0,95}$"); } - if !is_bounded_release_token(&profile.version, 64) { + if !is_valid_release_version(&profile.version) { return release_error( - "attribute_release_profiles version must be one bounded token of at most 64 bytes", + "attribute_release_profiles version must match \ + ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ for portable URL path use", ); } if profile diff --git a/crates/registry-relay/src/error.rs b/crates/registry-relay/src/error.rs index 13ddb51bd..d073a382e 100644 --- a/crates/registry-relay/src/error.rs +++ b/crates/registry-relay/src/error.rs @@ -475,6 +475,13 @@ pub enum QueryError { /// record; they are **never** surfaced in HTTP responses. #[derive(Debug, Clone, Error)] pub enum ReleaseError { + /// The resolve request body is malformed or does not match the closed + /// request schema. + #[error("release request invalid")] + InvalidRequest, + /// The resolve request did not use the required JSON media type. + #[error("release media type unsupported")] + UnsupportedMediaType, /// The requested release profile id/version is not registered. /// Renders as generic 404 — does **not** confirm profile enumeration. #[error("release profile not found")] @@ -1571,6 +1578,8 @@ impl SpatialError { impl ReleaseError { fn code(&self) -> &'static str { match self { + ReleaseError::InvalidRequest => "release.invalid_request", + ReleaseError::UnsupportedMediaType => "release.unsupported_media_type", ReleaseError::ProfileNotFound => "release.profile_not_found", ReleaseError::SubjectInvalid => "release.subject_invalid", ReleaseError::SubjectNotFound @@ -1583,6 +1592,8 @@ impl ReleaseError { fn http_status(&self) -> StatusCode { match self { + ReleaseError::InvalidRequest => StatusCode::BAD_REQUEST, + ReleaseError::UnsupportedMediaType => StatusCode::UNSUPPORTED_MEDIA_TYPE, ReleaseError::ProfileNotFound => StatusCode::NOT_FOUND, ReleaseError::SubjectInvalid => StatusCode::BAD_REQUEST, ReleaseError::SubjectNotFound @@ -1595,6 +1606,8 @@ impl ReleaseError { fn title(&self) -> &'static str { match self { + ReleaseError::InvalidRequest => "Invalid release request", + ReleaseError::UnsupportedMediaType => "Unsupported media type", ReleaseError::ProfileNotFound => "Not found", ReleaseError::SubjectInvalid => "Subject invalid", ReleaseError::SubjectNotFound @@ -1607,6 +1620,12 @@ impl ReleaseError { fn detail(&self) -> &'static str { match self { + ReleaseError::InvalidRequest => { + "request body must match the attribute-release resolve schema" + } + ReleaseError::UnsupportedMediaType => { + "request body must use the application/json media type" + } ReleaseError::ProfileNotFound => "the requested resource was not found", ReleaseError::SubjectInvalid => { "subject identifier type or value is not valid for this request" @@ -1631,6 +1650,8 @@ impl ReleaseError { /// **Never** include this value in HTTP responses. pub fn audit_code(&self) -> &'static str { match self { + ReleaseError::InvalidRequest => "release.invalid_request", + ReleaseError::UnsupportedMediaType => "release.unsupported_media_type", ReleaseError::ProfileNotFound => "release.profile_not_found", ReleaseError::SubjectInvalid => "release.subject_invalid", ReleaseError::SubjectNotFound => "release.subject_not_found", diff --git a/crates/registry-relay/src/lib.rs b/crates/registry-relay/src/lib.rs index 9f7ed6a2f..b51d5de8d 100644 --- a/crates/registry-relay/src/lib.rs +++ b/crates/registry-relay/src/lib.rs @@ -49,25 +49,13 @@ pub mod spdci; mod state_plane; pub mod table_provider; -/// Exact Cargo feature set compiled into this Relay binary. Operators can read -/// the same list through the protected admin capabilities endpoint, and release -/// image metadata records the canonical production value independently. +include!(concat!(env!("OUT_DIR"), "/compiled_cargo_features.rs")); + +/// Exact Cargo feature set compiled into this Relay binary. The inventory is +/// generated from Cargo's active feature environment, so operators can compare +/// the protected admin capabilities endpoint with release image metadata +/// without maintaining a second feature list in Rust. #[must_use] pub fn compiled_cargo_features() -> &'static [&'static str] { - &[ - #[cfg(feature = "attribute-release")] - "attribute-release", - #[cfg(feature = "crosswalk-runtime")] - "crosswalk-runtime", - #[cfg(feature = "standards-cel-mapping")] - "standards-cel-mapping", - #[cfg(feature = "spdci-api-standards")] - "spdci-api-standards", - #[cfg(feature = "ogcapi-features")] - "ogcapi-features", - #[cfg(feature = "ogcapi-edr")] - "ogcapi-edr", - #[cfg(feature = "ogcapi-records")] - "ogcapi-records", - ] + COMPILED_CARGO_FEATURES } diff --git a/crates/registry-relay/tests/attribute_release_api.rs b/crates/registry-relay/tests/attribute_release_api.rs index 03655359c..d191306e1 100644 --- a/crates/registry-relay/tests/attribute_release_api.rs +++ b/crates/registry-relay/tests/attribute_release_api.rs @@ -16,6 +16,7 @@ use std::sync::Arc; use axum::http::StatusCode; use axum::Extension; use axum_test::TestServer; +use bytes::Bytes; use datafusion::arrow::array::StringArray; use datafusion::arrow::datatypes::{DataType, Field, Schema}; use datafusion::arrow::record_batch::RecordBatch; @@ -818,6 +819,61 @@ async fn resolve_rejects_non_json_content_type() { let server = server().await; let response = server.post(RESOLVE_PATH).text("subject=NID-1").await; response.assert_status(StatusCode::UNSUPPORTED_MEDIA_TYPE); + assert_eq!( + response.json::()["code"], + "release.unsupported_media_type" + ); + assert_eq!( + response.header("cache-control").to_str().expect("ascii"), + "private, no-store" + ); + assert_eq!( + response.header("vary").to_str().expect("ascii"), + "Authorization" + ); +} + +#[tokio::test] +async fn resolve_normalizes_json_data_errors_to_problem_details() { + let server = server().await; + let response = server + .post(RESOLVE_PATH) + .json(&json!({ + "subject": { + "id_type": "NATIONAL_ID" + } + })) + .await; + response.assert_status(StatusCode::BAD_REQUEST); + let body = response.json::(); + assert_eq!(body["code"], "release.invalid_request"); + assert_eq!(body["status"], 400); + assert_eq!( + response.header("cache-control").to_str().expect("ascii"), + "private, no-store" + ); + assert_eq!( + response.header("vary").to_str().expect("ascii"), + "Authorization" + ); +} + +#[tokio::test] +async fn resolve_normalizes_json_syntax_errors_to_problem_details() { + let server = server().await; + let response = server + .post(RESOLVE_PATH) + .add_header("content-type", "application/json") + .bytes(Bytes::from_static(b"{")) + .await; + response.assert_status(StatusCode::BAD_REQUEST); + let body = response.json::(); + assert_eq!(body["code"], "release.invalid_request"); + assert_eq!(body["status"], 400); + assert_eq!( + response.header("cache-control").to_str().expect("ascii"), + "private, no-store" + ); } #[tokio::test] diff --git a/crates/registry-relay/tests/config_entities.rs b/crates/registry-relay/tests/config_entities.rs index d8eb3cf08..031431f5c 100644 --- a/crates/registry-relay/tests/config_entities.rs +++ b/crates/registry-relay/tests/config_entities.rs @@ -576,6 +576,26 @@ fn release_profile_empty_version_is_rejected() { assert_eq!(err, "config.validation_error"); } +#[cfg(feature = "attribute-release")] +#[test] +fn release_profile_version_must_be_a_portable_path_segment() { + for invalid in [ + "v1/preview", + "v1?draft", + "v1#draft", + "v1%2Fpreview", + "vérsion", + ] { + let profile = + valid_release_profile().replace("version: \"1\"", &format!("version: \"{invalid}\"")); + let err = + load_release_dataset(&profile).expect_err("reserved path character must be rejected"); + assert_eq!(err, "config.validation_error", "accepted {invalid:?}"); + } + let profile = valid_release_profile().replace("version: \"1\"", "version: \"v1.2_rc-1\""); + load_release_dataset(&profile).expect("portable version segment accepted"); +} + #[cfg(feature = "attribute-release")] #[test] fn release_profile_requires_at_least_one_required_claim() { diff --git a/crates/registry-relay/tests/error_taxonomy.rs b/crates/registry-relay/tests/error_taxonomy.rs index b81699a79..1bc0882f0 100644 --- a/crates/registry-relay/tests/error_taxonomy.rs +++ b/crates/registry-relay/tests/error_taxonomy.rs @@ -123,6 +123,8 @@ fn all_variants() -> Vec { // query.* Error::Query(QueryError::CursorInvalid), // release.* + Error::Release(ReleaseError::InvalidRequest), + Error::Release(ReleaseError::UnsupportedMediaType), Error::Release(ReleaseError::ProfileNotFound), Error::Release(ReleaseError::SubjectInvalid), Error::Release(ReleaseError::SubjectNotFound), @@ -288,6 +290,11 @@ fn expected_table() -> Vec<(&'static str, StatusCode)> { ("spatial.crs_unsupported", StatusCode::BAD_REQUEST), ("query.cursor_invalid", StatusCode::BAD_REQUEST), // release.* + ("release.invalid_request", StatusCode::BAD_REQUEST), + ( + "release.unsupported_media_type", + StatusCode::UNSUPPORTED_MEDIA_TYPE, + ), ("release.profile_not_found", StatusCode::NOT_FOUND), ("release.subject_invalid", StatusCode::BAD_REQUEST), // SubjectNotFound, SubjectAmbiguous, SubjectReleaseDenied, ClaimUnavailable @@ -544,6 +551,14 @@ async fn error_into_response_attaches_error_code_extension() { ), ("internal.timeout", Error::Internal(InternalError::Timeout)), // release.*: non-collapsed variants carry their own public code. + ( + "release.invalid_request", + Error::Release(ReleaseError::InvalidRequest), + ), + ( + "release.unsupported_media_type", + Error::Release(ReleaseError::UnsupportedMediaType), + ), ( "release.profile_not_found", Error::Release(ReleaseError::ProfileNotFound), @@ -666,6 +681,8 @@ fn audit_code_differs_from_public_code_for_collapsed_variants() { // Non-collapsed variants: audit_code() == code(). let non_collapsed = [ + ReleaseError::InvalidRequest, + ReleaseError::UnsupportedMediaType, ReleaseError::ProfileNotFound, ReleaseError::SubjectInvalid, ReleaseError::SourceUnavailable, diff --git a/crates/registry-relay/tests/feature_profile.rs b/crates/registry-relay/tests/feature_profile.rs new file mode 100644 index 000000000..96ccc7a8f --- /dev/null +++ b/crates/registry-relay/tests/feature_profile.rs @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +#[path = "../build_support.rs"] +mod build_support; + +use std::fs; +use std::path::Path; + +#[test] +fn custom_feature_profile_must_match_cargo_effective_features() { + let manifest = + fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")).unwrap(); + let declared = build_support::declared_feature_names(&manifest).unwrap(); + let enabled = vec![ + "attribute-release".to_string(), + "crosswalk-runtime".to_string(), + ]; + + assert!(build_support::validate_requested_profile( + "attribute-release,crosswalk-runtime", + &declared, + &enabled, + ) + .is_ok()); + let incomplete = + build_support::validate_requested_profile("attribute-release", &declared, &enabled) + .unwrap_err(); + assert!(incomplete.contains("missing: [crosswalk-runtime]")); + let unordered = build_support::validate_requested_profile( + "crosswalk-runtime,attribute-release", + &declared, + &enabled, + ) + .unwrap_err(); + assert!(unordered.contains("canonical order")); +} + +#[test] +fn compiled_feature_inventory_comes_from_cargo_manifest() { + let manifest = + fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")).unwrap(); + let declared = build_support::declared_feature_names(&manifest).unwrap(); + assert!(registry_relay::compiled_cargo_features() + .iter() + .all(|feature| declared.iter().any(|declared| declared == feature))); +} diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index d54045bef..c1f0bef50 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -10,7 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Attribute-release project authoring now emits the stable Relay profile contract with required purpose, exact subject type, and exact-one lookup - semantics. + semantics. It rejects non-portable profile version path segments and Relay + cross-field prerequisites before product activation. ### Removed diff --git a/crates/registryctl/src/project_authoring/output.rs b/crates/registryctl/src/project_authoring/output.rs index 9cdaf4265..0ae4d5bd2 100644 --- a/crates/registryctl/src/project_authoring/output.rs +++ b/crates/registryctl/src/project_authoring/output.rs @@ -1326,6 +1326,20 @@ fn validate_header_token(value: &str, field: &str, max_bytes: usize) -> Result<( Ok(()) } +fn validate_release_version(value: &str, field: &str) -> Result<()> { + let mut bytes = value.bytes(); + if value.is_empty() + || value.len() > 64 + || !matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9')) + || !bytes.all( + |byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'-'), + ) + { + bail!("{field} must match [A-Za-z0-9][A-Za-z0-9._-]{{0,63}}"); + } + Ok(()) +} + fn validate_scopes(scopes: &[String]) -> Result<()> { if scopes.is_empty() || scopes.len() > 16 { bail!("caller scopes must contain between one and 16 entries"); diff --git a/crates/registryctl/src/project_authoring/project.rs b/crates/registryctl/src/project_authoring/project.rs index 40622b0d1..23892a263 100644 --- a/crates/registryctl/src/project_authoring/project.rs +++ b/crates/registryctl/src/project_authoring/project.rs @@ -1471,7 +1471,7 @@ fn validate_record_attribute_release_profiles( "attribute release profile id must match [a-z][a-z0-9_-]{{0,95}}" ); } - validate_token(&profile.version, "attribute release profile version", 64)?; + validate_release_version(&profile.version, "attribute release profile version")?; if let Some(title) = &profile.title { validate_authored_text(title, "attribute release profile title")?; } diff --git a/crates/registryctl/src/project_authoring/tests.rs b/crates/registryctl/src/project_authoring/tests.rs index 8a450b1a0..fe36aec7c 100644 --- a/crates/registryctl/src/project_authoring/tests.rs +++ b/crates/registryctl/src/project_authoring/tests.rs @@ -525,6 +525,39 @@ outputs: ); } + #[test] + fn attribute_release_version_must_be_a_portable_path_segment() { + let mut loaded = load_registry_project( + &project_golden("nia-attribute-release"), + Some("local"), + ) + .expect("NIA release project loads"); + loaded + .project + .services + .get_mut("nia-population-records") + .and_then(|service| service.api.as_mut()) + .and_then(|api| { + api.attribute_release_profiles + .get_mut("solmara-nia-userinfo") + }) + .expect("release profile exists") + .version = "v1/preview".to_string(); + + let error = validate_project_entity_links( + &loaded.project, + &loaded.integrations, + &loaded.entities, + ) + .expect_err("path-reserved profile version must fail during authoring"); + assert!( + error + .to_string() + .contains("version must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}"), + "unexpected diagnostic: {error:#}" + ); + } + #[test] fn attribute_release_prerequisites_fail_during_authoring() { for case in ["required principal filters", "pagination max_limit"] { diff --git a/deny.toml b/deny.toml index 5b2c68bf0..f0f74f857 100644 --- a/deny.toml +++ b/deny.toml @@ -73,7 +73,9 @@ workspace-default-features = "allow" external-default-features = "allow" allow = [] allow-workspace = false -deny = [] +deny = [ + { name = "cel", deny-multiple-versions = true }, +] skip = [] skip-tree = [] diff --git a/release/scripts/build-release-binaries.sh b/release/scripts/build-release-binaries.sh index a2fd2a7b0..86b6c18fa 100755 --- a/release/scripts/build-release-binaries.sh +++ b/release/scripts/build-release-binaries.sh @@ -68,7 +68,8 @@ docker run --rm \ cp target/release/registryctl "dist/bin/registryctl-${RELEASE_TAG}-linux-amd64" cp target/release/registry-manifest "dist/bin/registry-manifest-${RELEASE_TAG}-linux-amd64" - cargo build --release --locked \ + REGISTRY_RELAY_FEATURES="${RELEASE_RELAY_FEATURES}" \ + cargo build --release --locked \ -p registry-relay \ --no-default-features \ --features "${RELEASE_RELAY_FEATURES}" diff --git a/release/scripts/test_registry_release.py b/release/scripts/test_registry_release.py index 83c22b5b0..b0dc73dc5 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -387,6 +387,10 @@ def test_release_build_wrappers_are_executable_and_canonical(self) -> None: '--env RELEASE_RELAY_FEATURES="${relay_release_features}"', binary_recipe, ) + self.assertIn( + 'REGISTRY_RELAY_FEATURES="${RELEASE_RELAY_FEATURES}"', + binary_recipe, + ) self.assertIn('--features "${RELEASE_RELAY_FEATURES}"', relay_commands[0]) feature_check = ( "python3 release/scripts/check-release-relay-features.py "