diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 5778ca169..b5838b5d2 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -856,11 +856,19 @@ 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 + relay_features="$(` 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 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 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). +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/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/canonical-release-features.txt b/crates/registry-relay/canonical-release-features.txt new file mode 100644 index 000000000..c690d6b05 --- /dev/null +++ b/crates/registry-relay/canonical-release-features.txt @@ -0,0 +1 @@ +attribute-release,crosswalk-runtime diff --git a/crates/registry-relay/deny.toml b/crates/registry-relay/deny.toml index ae175bcf4..aa8bc2d83 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 @@ -69,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 a69185d55..c98b6d615 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 @@ -439,16 +439,16 @@ 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 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: @@ -459,13 +459,21 @@ 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`. 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 when one is set. Either failure returns +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 @@ -491,15 +499,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..e3406f175 100644 --- a/crates/registry-relay/docs/ops.md +++ b/crates/registry-relay/docs/ops.md @@ -197,14 +197,23 @@ 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 feature set in +[`canonical-release-features.txt`](../canonical-release-features.txt), +currently `attribute-release,crosswalk-runtime`. 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,ogcapi-edr,spdci-api-standards,standards-cel-mapping \ scripts/build-image.sh registry-relay:-standards ``` +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 the standards-enabled image tag or digest in the release evidence. diff --git a/crates/registry-relay/docs/release-notes.md b/crates/registry-relay/docs/release-notes.md index 99af026ea..664f61a15 100644 --- a/crates/registry-relay/docs/release-notes.md +++ b/crates/registry-relay/docs/release-notes.md @@ -2,6 +2,52 @@ ## 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. + + 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/docs/security-assurance.md b/crates/registry-relay/docs/security-assurance.md index 1bea90734..b9e414789 100644 --- a/crates/registry-relay/docs/security-assurance.md +++ b/crates/registry-relay/docs/security-assurance.md @@ -116,7 +116,7 @@ to the exact candidate digest; the source checks do not substitute for them. Relay has two OpenAPI shapes: - `openapi/registry-relay.openapi.json` is the curated release artifact for the - default feature build. + exact feature profile in `canonical-release-features.txt`. - Runtime OpenAPI is config-expanded, scope-filtered, and may inline parameters. Because generated-vs-curated comparison creates false positives, the Relay @@ -126,10 +126,10 @@ existing Rust tests. A future normalizer may replace this with generated-vs-normalized comparison once both shapes can be canonicalized without losing security scheme or route semantics. -Default-feature manifest entries marked `openapi: true` are compared against the -curated OpenAPI artifact with path-parameter normalization. Feature-gated +Canonical-release manifest entries marked `openapi: true` are compared against +the curated OpenAPI artifact with path-parameter normalization. Feature-gated manifest entries remain in the exposure inventory, but they are not required in -the default artifact unless the default feature set enables them. +the release artifact unless `canonical-release-features.txt` enables them. ## Image release evidence @@ -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 in `canonical-release-features.txt`; optional feature gates remain +experimental. Protected public routes without a feature gate or behind a +canonical release 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/openapi/registry-relay.openapi.json b/crates/registry-relay/openapi/registry-relay.openapi.json index 169377cb6..6d095c87e 100644 --- a/crates/registry-relay/openapi/registry-relay.openapi.json +++ b/crates/registry-relay/openapi/registry-relay.openapi.json @@ -1172,6 +1172,206 @@ "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); 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" + }, + "maxItems": 32, + "minItems": 1, + "type": [ + "array", + "null" + ], + "uniqueItems": true + }, + "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": "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": [ + "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 +5280,293 @@ ] } }, + "/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": "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", + "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: missing Data-Purpose header, malformed or schema-invalid body, unknown id_type, or invalid claims 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": "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": { + "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." + }, + "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": { + "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 +8066,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 +8107,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-openapi-contract.sh b/crates/registry-relay/scripts/check-openapi-contract.sh index ae157e48d..c0be35699 100755 --- a/crates/registry-relay/scripts/check-openapi-contract.sh +++ b/crates/registry-relay/scripts/check-openapi-contract.sh @@ -15,10 +15,13 @@ FILTERED_CURRENT="$WORK_DIR/current.stable.openapi.json" ROSTER_PATH="../../docs/site/src/data/generated/relay-support.json" BASE_ROSTER="$WORK_DIR/base.relay-support.json" FILTER_SCRIPT="../../release/scripts/filter-relay-openapi-stability.py" +RELEASE_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 3c4555df2..516b2dbe6 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,26 +72,101 @@ 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), + ("crosswalk-runtime,standards-cel-mapping", True), + ("ogcapi-edr", True), + ("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: + 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 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=""', - "empty-by-default feature build arg", + f'ARG REGISTRY_RELAY_FEATURES="{canonical_feature_profile}"', + "canonical feature build arg", ) ) failures.extend( require( dockerfile, - 'cargo build --release --locked --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", ) ) + failures.extend( + require( + dockerfile, + "COPY scripts/validate-feature-profile.sh ./scripts/validate-feature-profile.sh", + "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 profile syntax validation", + ) + ) failures.extend( require( dockerfile, @@ -101,8 +177,15 @@ def main() -> int: failures.extend( require( dockerfile, - "cargo build --release --locked", - "default cargo build path", + 'REGISTRY_RELAY_FEATURES="" 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/scripts/check_security_assurance.py b/crates/registry-relay/scripts/check_security_assurance.py index b1402df23..98eaa8be7 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 @@ -48,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 @@ -84,6 +86,61 @@ def load_json(path: Path) -> object: fail(f"{path.relative_to(ROOT)} is not valid JSON: {exc}") +def load_release_features() -> set[str]: + cargo_path = ROOT / "Cargo.toml" + profile_path = ROOT / "canonical-release-features.txt" + try: + document = tomllib.loads(cargo_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") + + 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) + or release_features != sorted(release_features) + ): + fail( + "canonical-release-features.txt must contain unique, sorted, " + "comma-separated Cargo feature names" + ) + + 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 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 + + def fail(message: str) -> None: print(f"security assurance check failed: {message}", file=sys.stderr) raise SystemExit(1) @@ -126,6 +183,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") + 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") @@ -150,7 +208,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, 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): @@ -199,11 +257,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, release_features: set[str] | None = None +) -> None: + release_features = release_features or set() + feature = entry["feature"] + if ( + feature is not None + and feature not in release_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 +480,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) + 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") @@ -432,14 +500,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_release_openapi_entry( + entry, release_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_release_openapi_entry(entry, release_features) and (openapi_path_shape(entry["path"]), entry["method"]) not in openapi_op_keys ) if missing: @@ -478,8 +548,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_release_openapi_entry( + entry: dict, release_features: set[str] | None = None +) -> bool: + feature = entry.get("feature") + return feature is None or feature in (release_features or set()) def openapi_path_shape(path: str) -> str: 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..22243f396 --- /dev/null +++ b/crates/registry-relay/scripts/validate-feature-profile.sh @@ -0,0 +1,37 @@ +#!/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 + +seen="," +previous="" +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}," + if [ -n "$previous" ] && [ "$previous" \> "$feature" ]; then + fail "feature list must use canonical alphabetical order" + fi + previous=$feature + done + IFS=$previous_ifs +fi 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..59bf8e187 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 @@ -21,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; @@ -42,7 +42,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}; @@ -81,17 +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 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 { @@ -111,20 +117,60 @@ 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 { + 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()), + }; + 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 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); + } }; - // 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; + let result = run_resolve(&runtime, &route, &headers, Some(principal), body).await; match result { Ok(success) => { let response = with_audit_context( @@ -141,12 +187,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) } } } @@ -186,6 +232,30 @@ 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, + ) -> 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". @@ -246,14 +316,15 @@ 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` - // 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 +340,22 @@ 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::governed_request( + AuthError::PurposeDenied, + pdp_audit, + )); + } + None => { + return Err(ResolveRunError::governed_request( + AuthError::PurposeRequired, + pdp_audit, + )); } } @@ -351,12 +418,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 +446,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 +516,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 +525,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) { @@ -498,8 +560,9 @@ 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; 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, @@ -510,13 +573,21 @@ fn resolve_requested_claims<'a>( let Some(names) = requested else { return Ok(route.profile.claims.iter().collect()); }; - if names.is_empty() { - return Err(ResolveRunError::from(Error::from( - FilterError::InvalidValue, - ))); + if names.is_empty() || names.len() > MAX_ATTRIBUTE_RELEASE_CLAIMS { + 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::invalid_claim_request( + subject_id_raw.clone(), + pdp_audit.clone(), + )); + } match route .profile .claims @@ -538,6 +609,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) } @@ -663,15 +745,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 +885,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 +910,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, @@ -873,6 +944,25 @@ 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 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 04332cf0b..ebb6592e2 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 @@ -1797,7 +1799,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.", })); @@ -4151,17 +4153,21 @@ 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 or \ + schema-invalid body, unknown id_type, or invalid claims list." ), "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. 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." + ), + "415": problem_response( + "Unsupported media type: request body must be application/json." ), "503": problem_response("Source unavailable."), "default": problem_response("Problem Details error response."), @@ -4169,6 +4175,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", @@ -5145,6 +5157,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, @@ -5237,31 +5268,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", @@ -5310,19 +5339,25 @@ 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 }, "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.", + 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" }, - "nullable": true + "minItems": 1, + "maxItems": MAX_ATTRIBUTE_RELEASE_CLAIMS, + "uniqueItems": true } }, "additionalProperties": false, @@ -5347,11 +5382,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 +5412,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 +5439,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 +5493,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 +5508,9 @@ mod tests { sensitivity: Some(ClaimSensitivity::DirectIdentifier), format: None, locale: None, - shareable: false, }], response: ReleaseResponseConfig { include_source_metadata: false, - max_age_seconds: Some(300), }, }); } @@ -6422,6 +6448,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!( @@ -6436,6 +6479,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" @@ -6461,12 +6508,26 @@ 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" + ); + 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"]; 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..52519f6bc 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,8 @@ pub use disabled::{ mod enabled { use super::AttributeReleaseError; - use std::collections::HashMap; + use cel::common::ast::{EntryExpr, Expr, IdedExpr}; + use std::collections::{BTreeSet, HashMap}; use std::sync::{Arc, RwLock}; use serde_json::{json, Value}; @@ -241,10 +242,103 @@ 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 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 program = cel::Program::compile(expression).map_err(|_| ())?; + let mut roots = BTreeSet::new(); + 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()); + } + } + 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); + } + for argument in &call.args { + collect_cel_roots(argument, locals, roots); + } + } + Expr::List(list) => { + for element in &list.elements { + collect_cel_roots(element, locals, roots); + } + } + Expr::Map(map) => { + for entry in &map.entries { + collect_cel_entry_roots(&entry.expr, locals, roots); + } + } + Expr::Struct(value) => { + for entry in &value.entries { + collect_cel_entry_roots(&entry.expr, locals, roots); + } + } + 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()); + } + 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); + } + } + } + /// 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 +536,79 @@ 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_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 [ + "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_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") + .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..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. @@ -1624,10 +1625,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 +1640,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 +1662,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 +1709,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 +1730,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 +2350,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..6582bfc1e 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. @@ -3698,6 +3698,32 @@ 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_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 + && 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 +3731,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 +3776,48 @@ 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_valid_release_version(&profile.version) { + return release_error( + "attribute_release_profiles version must match \ + ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ for portable URL path use", + ); + } + 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 !profile.purpose.is_ascii() || !is_bounded_release_token(&profile.purpose, 256) { + return release_error( + "attribute_release_profiles purpose must be one visible-ASCII bounded token of at most 256 bytes", + ); } - if profile.version.trim().is_empty() { - return release_error("attribute_release_profiles version must not be empty"); + 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 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 +3826,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() > 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(); 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 +3878,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 +3951,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 +5582,6 @@ deployment: sensitivity: None, format: None, locale: None, - shareable: true, } } 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 53b036725..b51d5de8d 100644 --- a/crates/registry-relay/src/lib.rs +++ b/crates/registry-relay/src/lib.rs @@ -48,3 +48,14 @@ pub mod spdci; )] mod state_plane; pub mod table_provider; + +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] { + COMPILED_CARGO_FEATURES +} diff --git a/crates/registry-relay/tests/attribute_release_api.rs b/crates/registry-relay/tests/attribute_release_api.rs index 4b54804b2..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; @@ -72,23 +73,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 +160,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 +180,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 +209,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 +230,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 +282,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 +335,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; @@ -435,6 +418,36 @@ 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_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; @@ -564,7 +577,6 @@ async fn resolve_required_claim_missing_denies() { trusted_context: {} "#, true, - None, Some("identity"), ) .await @@ -592,7 +604,6 @@ async fn resolve_optional_claim_omitted_when_source_redacted() { trusted_context: {} "#, true, - None, Some("identity"), ) .await @@ -628,7 +639,6 @@ async fn resolve_computed_claim_cannot_read_redacted_field() { trusted_context: {} "#, true, - None, Some("identity"), ) .await @@ -654,6 +664,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 +700,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 +717,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 +729,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 +742,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 +784,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"); @@ -772,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] @@ -857,8 +959,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 +975,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..031431f5c 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 @@ -575,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() { @@ -627,6 +648,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 +671,62 @@ 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_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() { + 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 +776,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/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/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/registry-relay/tests/security_assurance_check_test.py b/crates/registry-relay/tests/security_assurance_check_test.py index 80ca49ec3..8d4b465c5 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,21 @@ def write_contracts(self, manifest_entry): "endpoints": [manifest_entry], })) + 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" + f"default = {rendered_default}\n" + 'attribute-release = ["crosswalk-runtime"]\n' + "crosswalk-runtime = []\n" + "ogcapi-features = []\n" + ) + (self.root / "canonical-release-features.txt").write_text( + ",".join(release) + "\n" + ) + def entry(self, **overrides): base = { "service": "registry-relay", @@ -94,7 +110,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 +120,36 @@ def test_feature_gated_endpoint_must_remain_experimental(self): ) self.module.validate_manifest() + 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_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_release_features(), + {"attribute-release", "crosswalk-runtime"}, + ) + def test_core_record_read_routes_must_remain_stable(self): expected = { ( @@ -228,6 +274,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, diff --git a/crates/registryctl/CHANGELOG.md b/crates/registryctl/CHANGELOG.md index 13dca4bf5..c1f0bef50 100644 --- a/crates/registryctl/CHANGELOG.md +++ b/crates/registryctl/CHANGELOG.md @@ -6,6 +6,19 @@ 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. It rejects non-portable profile version path segments and Relay + cross-field prerequisites before product activation. + +### 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/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/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.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 f661a8f59..6d037eecb 100644 --- a/crates/registryctl/src/project_authoring/compiler/notary.rs +++ b/crates/registryctl/src/project_authoring/compiler/notary.rs @@ -859,48 +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 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; - } + 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()); } - if !closed { - bail!("CEL expression contains an unterminated string literal"); + } + 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); + } + 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); } - if bytes.get(index) == Some(&b'.') { - roots.insert(expression[start..index].to_string()); + } + Expr::Map(map) => { + for entry in &map.entries { + collect_cel_entry_roots(&entry.expr, locals, roots); } - continue; } - index += 1; + Expr::Struct(value) => { + for entry in &value.entries { + collect_cel_entry_roots(&entry.expr, locals, roots); + } + } + 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()); + } + 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); + } } - Ok(roots) } fn expanded_disclosure(disclosure: &DisclosureDeclaration) -> (&str, Vec<&str>) { 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/output.rs b/crates/registryctl/src/project_authoring/output.rs index bf040ab4e..0ae4d5bd2 100644 --- a/crates/registryctl/src/project_authoring/output.rs +++ b/crates/registryctl/src/project_authoring/output.rs @@ -1318,6 +1318,28 @@ 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_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 832a9d2a9..23892a263 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() @@ -1459,14 +1471,18 @@ 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")?; } 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"); } @@ -1487,8 +1503,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 +1547,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..fe36aec7c 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") @@ -494,6 +493,112 @@ 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_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"] { + 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"); @@ -2100,11 +2205,39 @@ 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()]) ); + 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_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_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()); } 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..f0f74f857 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 @@ -72,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/docs/site/scripts/relay-release-contract.test.mjs b/docs/site/scripts/relay-release-contract.test.mjs index 6c1468c2f..635a529ae 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,37 +126,78 @@ 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 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]; + 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'); + 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', + ); + + 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); assert.match( - dockerfile, - /^ARG REGISTRY_RELAY_FEATURES=""$/m, - 'the local production image must default to the canonical empty feature set', + releaseRecipe, + /relay_feature_profile=.*crates\/registry-relay\/canonical-release-features\.txt/, + 'the release recipe must read the canonical Relay feature profile', + ); + 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', ); - const workflowPath = process.env.RELAY_RELEASE_WORKFLOW_PATH ?? '.github/workflows/release.yml'; - const workflow = isAbsolute(workflowPath) - ? await readFile(workflowPath, 'utf8') - : await readRepo(workflowPath); - 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)), + const openapiContract = await readRepo( + 'crates/registry-relay/scripts/check-openapi-contract.sh', ); - assert.deepEqual( - workflowRelayFeatures, - canonicalFeatures, - 'the release workflow Relay features must match the canonical 1.0 roster', + 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( @@ -176,6 +225,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..86b6c18fa 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 @@ -63,9 +68,11 @@ 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 + --no-default-features \ + --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 51da6c650..96ef60727 100755 --- a/release/scripts/build-release-image.sh +++ b/release/scripts/build-release-image.sh @@ -33,6 +33,17 @@ case "${name}" in ;; 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=${relay_release_features}" + ) +fi + cache_args=() if [[ -n "${RELEASE_IMAGE_CACHE_FROM:-}" ]]; then cache_args+=(--cache-from "${RELEASE_IMAGE_CACHE_FROM}") @@ -160,6 +171,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..9e03c8151 100755 --- a/release/scripts/smoke-release-image-oci-labels.sh +++ b/release/scripts/smoke-release-image-oci-labels.sh @@ -14,6 +14,10 @@ revision_label="0123456789abcdef0123456789abcdef01234567" wrong_revision_label="89abcdef0123456789abcdef0123456789abcdef" version_label="v0.0.0-oci-label-smoke" source_date_epoch=0 +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}" @@ -102,10 +106,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 +137,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 +145,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 +154,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..33019be84 100644 --- a/release/scripts/test_check_release_image_oci_labels.py +++ b/release/scripts/test_check_release_image_oci_labels.py @@ -23,6 +23,13 @@ SOURCE = "https://github.com/registrystack/registry-stack" REVISION = "b" * 40 VERSION = "v0.12.0" +RELAY_FEATURE_LABEL = "org.registrystack.registry-relay.features" +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" @@ -317,6 +324,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 +459,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 +661,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..b0dc73dc5 100755 --- a/release/scripts/test_registry_release.py +++ b/release/scripts/test_registry_release.py @@ -270,6 +270,14 @@ def test_release_images_publish_and_executably_verify_oci_labels(self) -> None: self.assertIn( '--version "${{ needs.validate.outputs.version }}"', verification_job ) + self.assertIn( + 'relay_features="$( None: @@ -371,6 +379,19 @@ def test_release_build_wrappers_are_executable_and_canonical(self) -> 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( + '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 " "target/release/registry-relay" @@ -389,6 +410,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) 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": [